From 977ef3a1d303a896efbc71a507e94228312d7335 Mon Sep 17 00:00:00 2001 From: le king fu Date: Mon, 12 Oct 2026 16:45:18 -0400 Subject: [PATCH] docs: ADR 0024, MCP server (proposed) 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 --- docs/adr/0024-serveur-mcp.md | 87 ++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + 2 files changed, 88 insertions(+) create mode 100644 docs/adr/0024-serveur-mcp.md diff --git a/docs/adr/0024-serveur-mcp.md b/docs/adr/0024-serveur-mcp.md new file mode 100644 index 0000000..d19e902 --- /dev/null +++ b/docs/adr/0024-serveur-mcp.md @@ -0,0 +1,87 @@ +# 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 depuis `spec-plan-mcp-server.md`), revue `/review-spec` du 2026-10-12 +- S'appuie sur l'[ADR 0017](0017-feature-gating-par-tier.md) (gating par édition, `FEATURE_TIERS` côté Rust), l'[ADR 0021](0021-migrations-profils-secondaires.md) (`prepare_at`, rattrapage des profils secondaires) et l'[ADR 0022](0022-canal-evenements-donnees.md) (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, seul `rusqlite` ouvre 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/.ts:`. + +### 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 : retirer `windows_subsystem` et 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 `dataEvents` natif, mais l'app doit tourner, un binaire de plus dans `externalBin` et `release.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 `refreshKey` au focus** : trois lignes, mais la page perdrait filtres et défilement à chaque retour. +- **`wal_checkpoint(TRUNCATE)` avant `VACUUM INTO`** : inutile (la copie lit la vue de la connexion, WAL compris) et nuisible (échoue en `SQLITE_BUSY` dès qu'un lecteur est ouvert). diff --git a/docs/architecture.md b/docs/architecture.md index a938db5..0322fc8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -711,3 +711,4 @@ Les ADRs documentent les décisions techniques structurantes. Ils vivent dans `d | [0021](adr/0021-migrations-profils-secondaires.md) | Les profils secondaires rattrapent leurs migrations à l'ouverture | 2026-10-06 | Accepted | | [0022](adr/0022-canal-evenements-donnees.md) | Canal d'événements de données : les écritures signalent, le parcours de démarrage relit | 2026-10-08 | Accepted | | [0023](adr/0023-windows-sys-pinning.md) | Épinglage de `windows-sys` 0.61.2 : garde-fou CI sur la version résolue, condition de sortie écrite | 2026-10-10 | Accepted | +| [0024](adr/0024-serveur-mcp.md) | Serveur MCP : un mode `--mcp` du binaire, stdio seul, accès par profil, Premium | 2026-10-12 | Proposed |