@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,102 +1,356 @@
1
- # 🚀 MCP SFTP/SSH Orchestrator
1
+ # 🚀 MCP Orchestrator — Serveur d'orchestration SSH/SFTP
2
2
 
3
- Un serveur MCP (Model-Context-Protocol) puissant pour l'orchestration de tâches distantes. Il gère des connexions SSH et SFTP, une file d'attente de tâches persistante, et expose un ensemble riche d'outils pour la gestion de serveurs, le monitoring, et l'exécution de commandes via une interface `stdio` compatible avec les LLM.
3
+ > **v11.8.0 Security Refresh** Les 82 tools publient désormais les annotations MCP standard. `infra_overview` reste léger sans argument, mais `infra_overview { alias: "..." }` effectue une découverte live et corrèle Nginx/domaines, ports, Docker/Compose et services. Voir `CHANGELOG.md`.
4
4
 
5
- ✨ **Fonctionnalités Principales**
6
5
 
7
- - **Gestion de Serveurs** : Ajoutez, listez et supprimez des configurations de serveurs SSH/SFTP.
8
- - **Exécution de Tâches SSH** : Simple, interactive, ou en séquence.
9
- - **Transferts de Fichiers SFTP** : Upload/Download de fichiers et dossiers, avec support des patterns `glob`.
10
- - **File d'Attente Intelligente** : Mode hybride (synchrone/asynchrone) et persistance des tâches.
11
- - **Pooling de Connexions** : Gère un pool de connexions SSH pour une exécution ultra-rapide.
12
- - **Outils de Monitoring** : Surveillez les ressources système, le statut des services (systemd, Docker, PM2) et Fail2Ban.
13
- - **Outils de Logs** : Récupérez les logs de PM2, Docker, ou suivez la fin d'un fichier (`tail`).
6
+ **Version** : 11.8.0
7
+ **Tools** : 82
8
+ **License** : MIT
9
+ **Node** : >= 18.0.0
10
+ **Changelog** : [CHANGELOG.md](./CHANGELOG.md)
11
+
12
+ Serveur MCP (Model Context Protocol, transport **stdio**) qui donne à un agent IA la capacité d’orchestrer un parc de serveurs : SSH, SFTP, édition de fichiers locale/remote (hash-safe), diffs cross-server, shell PTY, snapshots d’infra, notes de protocole, **projets**, **sessions de travail**, inventaire, trust SSH (pubkey), groupes d’alias et audit parc.
13
+
14
+ ---
15
+
16
+ ## ✨ Points forts (v11.6)
17
+
18
+ | Domaine | Capacité |
19
+ |---------|----------|
20
+ | **Exécution** | `task_exec` multi-serveur / `group:oci` / dry-run destructif / force |
21
+ | **Fichiers** | `file_read` / `file_edit` (chirurgical) / `file_write` + hash + dryRun + backup |
22
+ | **Parc** | notes, `infra_audit`, `fleet_status`, `server_inventory` |
23
+ | **Projets** | registre local↔remote + `project_diff` |
24
+ | **Travail** | `work_start` → edits → `work_end` (note d’intervention auto) |
25
+ | **Trust** | `ssh_authorize_key` (pubkey only, dry_run par défaut) |
26
+ | **Sécu** | secrets masqués, RO global + **RO par alias**, blocklist, pool SSH robuste |
27
+
28
+ ---
14
29
 
15
30
  ## 📦 Installation
16
31
 
17
- Ce projet est conçu pour être utilisé comme un outil MCP dans un environnement compatible (comme la `gemini-cli`).
32
+ ```bash
33
+ git clone https://github.com/fkom13/mcp-sftp-orchestrator.git
34
+ cd sftp-mcp # ou tools/sftp-mcp
35
+ npm install
36
+ cp .env.example .env
37
+ # Éditer MCP_DATA_DIR et chemins de clés
38
+ ```
39
+
40
+ Prérequis : **Node.js >= 18**
41
+
42
+ ---
18
43
 
19
- Enregistrez ce MCP auprès de votre client en utilisant la configuration suivante :
44
+ ## ⚙️ Configuration (`.env`)
45
+
46
+ Toutes les variables sont optionnelles.
47
+
48
+ | Variable | Défaut | Description |
49
+ |----------|--------|-------------|
50
+ | `MCP_DATA_DIR` | `~/.config/mcp-orchestrator` | Dossier data (JSON, snapshots, projets…) |
51
+ | `MCP_SYNC_TIMEOUT_S` | `120` | Délai (s) avant passage d’une tâche en arrière-plan |
52
+ | `MCP_DEFAULT_CMD_TIMEOUT_S` | `600` | Timeout SSH commande (s). `0` = infini |
53
+ | `MCP_INTERACTIVE_CMD_TIMEOUT_S` | `300` | Timeout interactif (s). `0` = infini |
54
+ | `MCP_MAX_WAIT_TIMEOUT_S` | `600` | Timeout max `task_wait` (s) |
55
+ | `MAX_CONNECTIONS_PER_SERVER` | `5` | Pool SSH max / serveur |
56
+ | `MIN_CONNECTIONS_PER_SERVER` | `1` | Pool SSH min / serveur |
57
+ | `IDLE_TIMEOUT` | `300000` | Fermeture connexion inactive (ms) |
58
+ | `KEEP_ALIVE_INTERVAL` | `30000` | Keepalive SSH (ms) |
59
+ | `MAX_QUEUE_SIZE` | `1000` | Taille max queue jobs |
60
+ | `SAVE_INTERVAL` | `5000` | Autosave queue (ms) |
61
+ | `MCP_ALLOWED_ROOTS` | _(vide)_ | Racines autorisées pour paths locaux (CSV) |
62
+ | `MCP_READONLY` | `false` | `1` = refuse écritures / exec mutantes (global) |
63
+ | `MCP_COMPACT` | `false` | `1` = réponses tronquées (tokens agent) |
64
+ | `MCP_DEBUG` | `false` | Logs détaillés stderr |
65
+
66
+ ### Fichiers sous `MCP_DATA_DIR`
67
+
68
+ | Fichier | Contenu |
69
+ |---------|---------|
70
+ | `servers.json` | Alias SSH (`host`, `user`, `keyPath`/`password`, `port?`, `readonly?`) |
71
+ | `apis.json` | Catalogue APIs (secrets masqués en lecture tools) |
72
+ | `queue.json` / `queue.backup.json` | Jobs |
73
+ | `history.json` | Historique tâches |
74
+ | `server_notes.json` | Protocoles / notes par serveur |
75
+ | `server_groups.json` | Groupes d’alias |
76
+ | `projects.json` | Registre projets |
77
+ | `work_sessions.json` | Sessions de travail |
78
+ | `policies.json` | Blocklist commandes |
79
+ | `tunnels.json` / `tunnel_allowlist.json` | Tunnels SSH |
80
+ | `infra_snapshots/` | Snapshots content-addressable |
81
+
82
+ ---
83
+
84
+ ## 🔌 Connexion client MCP
85
+
86
+ ### Grok / config.toml
87
+
88
+ ```toml
89
+ [mcp_servers.orchestrator]
90
+ command = "node"
91
+ args = ["/chemin/absolu/sftp-mcp/server.js"]
92
+ # optionnel:
93
+ # env = { MCP_DATA_DIR = "/chemin/absolu/sftp-mcp/data" }
94
+ ```
95
+
96
+ ### OpenCode / Claude Desktop (JSON)
20
97
 
21
98
  ```json
22
99
  {
23
100
  "mcpServers": {
24
- "mcp-sftp-orchestrator": {
25
- "command": "npx",
26
- "args": [
27
- "@fkom13/mcp-sftp-orchestrator"
28
- ],
101
+ "orchestrator": {
102
+ "command": "node",
103
+ "args": ["/chemin/absolu/sftp-mcp/server.js"],
29
104
  "env": {
30
- "MCP_DATA_DIR": "~/.config/mcp-orchestrator"
105
+ "MCP_DATA_DIR": "/chemin/absolu/sftp-mcp/data"
31
106
  }
32
107
  }
33
108
  }
34
109
  }
35
110
  ```
36
111
 
37
- Le client MCP lancera automatiquement le serveur via `npx` lors de son premier appel.
112
+ Après modification du code : recharger le serveur MCP (`/mcps``r` ou restart session). Vérifier `system_diagnostics` → `version: "11.8.0"`.
38
113
 
39
- ## 🛠️ Configuration
114
+ ---
40
115
 
41
- Le serveur est configurable via des variables d'environnement. Vous pouvez créer un fichier `.env` à la racine du projet si vous l'exécutez localement pour le développement.
116
+ ## 🧰 Référence des outils (82)
42
117
 
43
- - `MCP_DATA_DIR`: Le dossier où seront stockées les données (configurations des serveurs, historique, etc.). Par défaut : `~/.config/mcp-orchestrator`.
44
- - `MCP_SYNC_TIMEOUT_S`: Le délai en secondes avant qu'une tâche longue ne passe en arrière-plan. Par défaut : `30`.
118
+ ### Diagnostic & audit
119
+ | Outil | Description |
120
+ |-------|-------------|
121
+ | `help` | Guide outils + .env + astuces |
122
+ | `guide` | Manuel IA (workflows, cheatsheet, pitfalls, audit, security) |
123
+ | `system_diagnostics` | Queue, pool, serveurs/APIs **masqués**, version, readOnly |
124
+ | `infra_audit` | Synthèse parc + projets + notes + crashed |
125
+ | `infra_overview` | Serveurs + notes (vue légère) |
126
+ | `fleet_status` | Ping SSH parallèle (latence, load, disk) |
127
+ | `server_inventory` | Inventaire léger (pm2/docker/disk/home, cache 10 min) |
45
128
 
46
- ## 🧰 Référence des Outils (API)
129
+ ### Serveurs & groupes
130
+ | Outil | Description |
131
+ |-------|-------------|
132
+ | `server_add` | CRUD alias (`keyPath` ou `password`, `port`, **`readonly`**) |
133
+ | `server_list` | Liste (passwords masqués) |
134
+ | `server_remove` | Supprime un alias |
135
+ | `server_group_list/set/remove` | Groupes (`oci`, `contabo`…). Usage : `group:oci` ou nom de groupe |
47
136
 
48
- Voici la liste complète des outils exposés par ce serveur MCP.
137
+ ### Projets (v11.6)
138
+ | Outil | Description |
139
+ |-------|-------------|
140
+ | `project_list` / `project_get` / `project_set` / `project_remove` | Registre |
141
+ | `project_resolve` | → `{ local, remote, ignore, runtime }` |
142
+ | `project_diff` | Diff local↔remote du projet |
49
143
 
50
- ### Gestion des Serveurs
144
+ Exemple `project_set` :
145
+
146
+ ```json
147
+ {
148
+ "name": "p-image",
149
+ "local": { "path": "/home/.../dev-serveur/p-image" },
150
+ "servers": {
151
+ "prod": {
152
+ "alias": "fkomprodmini2_prod",
153
+ "path": "/home/ubuntu/p-image",
154
+ "runtime": { "pm2": "p-image", "port": 5002 },
155
+ "url": "https://pruna.esprit-artificiel.com"
156
+ }
157
+ },
158
+ "ignore": ["node_modules", ".git", "data"]
159
+ }
160
+ ```
161
+
162
+ ### Sessions de travail (v11.6)
163
+ | Outil | Description |
164
+ |-------|-------------|
165
+ | `work_start` | Ouvre un journal (`alias`, `project`, `tag`, snapshot optionnel) |
166
+ | `work_log` | Event (`file_edit`, `task_exec`, …) |
167
+ | `work_list` | Sessions actives (+ historique) |
168
+ | `work_end` | Clôture + `server_note` `last_intervention` |
169
+
170
+ ### Trust SSH (v11.6)
171
+ | Outil | Description |
172
+ |-------|-------------|
173
+ | `ssh_authorize_key` | Ajoute une **pubkey** dans `authorized_keys` distant. `dry_run` défaut. Sources : `string` \| `local_path` \| `alias` |
174
+
175
+ ```json
176
+ {
177
+ "target_alias": "fkomprodmini1_prod",
178
+ "source": { "type": "alias", "alias": "vps_contabo" },
179
+ "comment": "fleet-from-contabo",
180
+ "dry_run": true
181
+ }
182
+ ```
51
183
 
52
- - `server_add`: Enregistre ou met à jour les informations de connexion d'un serveur.
53
- - `server_list`: Affiche la liste de tous les alias de serveurs configurés.
54
- - `server_remove`: Supprime un alias de serveur de la configuration.
184
+ ### Policies
185
+ | Outil | Description |
186
+ |-------|-------------|
187
+ | `policy_blocklist_list/add/remove` | Blocklist commandes (aussi appliquée à shell + sequences) |
55
188
 
56
- ### Exécution de Tâches
189
+ ### Catalogue API
190
+ | Outil | Description |
191
+ |-------|-------------|
192
+ | `api_add` / `api_list` / `api_remove` / `api_check` | Monitoring (clés masquées en list) |
57
193
 
58
- - `task_exec`: Exécute une commande SSH (hybride synchrone/asynchrone).
59
- - `task_transfer`: Transfère un fichier ou dossier via SFTP (hybride synchrone/asynchrone).
60
- - `task_exec_interactive`: Exécute une commande SSH interactive (gère les prompts `yes/no`, etc.).
61
- - `task_exec_sequence`: Exécute plusieurs commandes SSH en séquence sur le même serveur.
62
- - `task_transfer_multi`: Transfère plusieurs fichiers/dossiers avec support de patterns `glob`.
194
+ ### Exécution de tâches
195
+ | Outil | Description |
196
+ |-------|-------------|
197
+ | `task_exec` | SSH ; alias \| tableau \| `all` \| `group:x` ; dry_run/force destructif |
198
+ | `task_exec_interactive` | Prompts yes/no, menus |
199
+ | `task_exec_sequence` | Séquence sur un serveur (policy par étape) |
200
+ | `task_transfer` | SFTP upload/download/`server_to_server` |
201
+ | `task_transfer_multi` | Multi + globs |
63
202
 
64
- ### Monitoring & Diagnostics
203
+ ### Files / Diff / Shell / Snapshots
204
+ | Famille | Outils |
205
+ |---------|--------|
206
+ | Files | `file_read`, `file_write`, `file_edit` |
207
+ | Diff | `diff_files`, `diff_folders`, `compare_all_sources` |
208
+ | Shell | `shell_create`, `shell_exec` (+ `skip_policy`), `shell_list`, `shell_close` |
209
+ | Snapshots | `snapshot_create/list/diff/restore/delete` |
65
210
 
66
- - `get_system_resources`: Récupère les métriques système vitales (CPU, RAM, Disque).
67
- - `get_services_status`: Récupère le statut des services (systemd, Docker, PM2).
68
- - `get_fail2ban_status`: Récupère les informations du service Fail2Ban.
211
+ **Édition safe** : `file_read` hash `file_edit` + `expectedHash` (+ `dryRun` / `backup`).
69
212
 
70
- ### Récupération de Logs
213
+ ### Notes serveur
214
+ | Outil | Description |
215
+ |-------|-------------|
216
+ | `server_note_set/get/list/remove` | Protocole (description, services, warnings, intervention) |
71
217
 
72
- - `get_pm2_logs`: Raccourci pour récupérer les logs PM2.
73
- - `get_docker_logs`: Raccourci pour récupérer les logs d'un container Docker.
74
- - `tail_file`: Affiche les dernières lignes d'un fichier distant.
218
+ ### Monitoring & logs
219
+ | Outil | Description |
220
+ |-------|-------------|
221
+ | `get_system_resources` | CPU / RAM / disque |
222
+ | `get_services_status` | systemd / Docker / PM2 |
223
+ | `get_fail2ban_status` | Fail2Ban |
224
+ | `check_api_health` | HTTP via SSH+curl |
225
+ | `get_pm2_logs` / `get_docker_logs` / `tail_file` | Logs |
75
226
 
76
- ### Gestion de la File d'Attente (Queue)
227
+ ### Queue
228
+ | Outil | Description |
229
+ |-------|-------------|
230
+ | `task_queue` / `task_status` / `task_history` / `task_wait` / `task_logs` | Suivi |
231
+ | `task_retry` / `task_retry_all` | Relance |
232
+ | `task_purge` | Purge (dry_run défaut) |
233
+ | `queue_stats` / `pool_stats` | Stats |
77
234
 
78
- - `task_queue`: Affiche le statut de toutes les tâches dans la file d'attente.
79
- - `task_status`: Récupère les détails d'une tâche par son ID.
80
- - `task_history`: Affiche l'historique des dernières tâches lancées.
81
- - `task_retry`: Relance une tâche qui a échoué ou crashé.
82
- - `queue_stats`: Affiche les statistiques de la queue de tâches.
235
+ ### Tmux & tunnels
236
+ | Outil | Description |
237
+ |-------|-------------|
238
+ | `tmux_create/exec/read/list/kill` | Sessions tmux distantes |
239
+ | `tunnel_create/list/close` | Tunnels SSH local/remote/socks |
240
+ | `tunnel_allowlist_add/remove` | Ports autorisés pour tunnels |
241
+
242
+ ---
243
+
244
+ ## 📖 Workflows agent recommandés
245
+
246
+ ### Début de session
247
+ ```
248
+ infra_audit (ou infra_overview)
249
+ fleet_status
250
+ project_list / project_resolve
251
+ ```
252
+
253
+ ### Chantier sur un projet
254
+ ```
255
+ work_start { project: "p-image", alias: "fkomprodmini2_prod", tag: "fix-x", message: "…" }
256
+ file_read → file_edit (expectedHash, dryRun puis apply)
257
+ work_log { type: "file_edit", path: "…" }
258
+ work_end { summary: "…" } → note serveur mise à jour
259
+ project_diff { name: "p-image" }
260
+ ```
261
+
262
+ ### Commandes longues
263
+ ```
264
+ task_exec { timeout: 0, … } → si > syncTimeout → task_wait { id }
265
+ ```
266
+
267
+ ### Cibles multi-serveurs
268
+ ```
269
+ task_exec { alias: "group:oci", cmd: "hostname" }
270
+ task_exec { alias: "all", cmd: "uptime" }
271
+ ```
272
+
273
+ ---
274
+
275
+ ## 🏗️ Architecture
276
+
277
+ ```
278
+ Client MCP (stdio)
279
+
280
+ server.js ─── 82 tools
281
+
282
+ ├── queue.js File d’attente persistante + purge/retry
283
+ ├── ssh.js / sshPool Exécution + pool (retry safe, port configurable)
284
+ ├── sftp.js Transferts (server_to_server via sourceAdapter/pool)
285
+ ├── sourceAdapter.js Local fs | remote SFTP pool
286
+ ├── fileOps.js Read/write/edit + hash + dryRun + backup
287
+ ├── diffEngine.js / compareEngine.js / diffFormatter.js
288
+ ├── shellSessions.js PTY persistants + policy
289
+ ├── snapshotManager.js
290
+ ├── projects.js / workSession.js / inventory.js / groups.js / fleet.js
291
+ ├── sshTrust.js authorized_keys (pubkey only)
292
+ ├── servers.js / apis.js / notes.js / policies.js / tunnels.js
293
+ ├── history.js / guide.js / config.js / utils.js
294
+ ```
295
+
296
+ ### Cycle de vie d’un job
297
+
298
+ ```
299
+ pending → running → completed | failed | partial
300
+ ↓ (redémarrage MCP pendant running)
301
+ crashed → task_retry → pending
302
+ ```
303
+
304
+ ---
305
+
306
+ ## 🔒 Sécurité
307
+
308
+ | Mécanisme | Détail |
309
+ |-----------|--------|
310
+ | Secrets | Masqués en `api_list` / diagnostics (`***` + 4 derniers car.) |
311
+ | Shell escape | `escapeShellArg` sur curl, logs, chemins |
312
+ | Blocklist | `policies.json` ; shell + sequence inclus ; `skip_policy` pour forcer |
313
+ | RO global | `MCP_READONLY=1` |
314
+ | RO alias | `"readonly": true` dans `servers.json` |
315
+ | Destructif | `task_exec` dry-run si pattern dangereux sans `force:true` |
316
+ | Trust | Pubkey only ; dry_run par défaut |
317
+ | Clés | Préférer `keyPath` SSH ; Vaultwarden pour secrets API |
318
+
319
+ ---
320
+
321
+ ## 🧪 Tests
322
+
323
+ ```bash
324
+ npm test:unit # p0 + p1 + p16 (43 tests)
325
+ npm test # unit + smoke MCP + features
326
+ node diagnose.js # diagnostic local optionnel
327
+ ```
83
328
 
84
- ### Gestion des APIs (Monitoring Externe)
329
+ | Fichier | Couverture |
330
+ |---------|------------|
331
+ | `test_p0_unit.js` | utils, policies, redact, timeouts, version |
332
+ | `test_p1_unit.js` | groups, purge, destructive, RO env |
333
+ | `test_p16_unit.js` | projects, work session, compact, sshTrust |
334
+ | `test_mcp.js` | smoke SDK |
335
+ | `test_features.js` | queue / pool / globs / prompts |
85
336
 
86
- - `api_add`: Ajoute une API au catalogue de monitoring.
87
- - `api_list`: Affiche toutes les APIs configurées.
88
- - `api_remove`: Supprime une API du catalogue.
89
- - `api_check`: Lance un test de santé sur une API.
337
+ ---
90
338
 
91
- ### Administration du Serveur MCP
339
+ ## 🛣️ Versions récentes
92
340
 
93
- - `task_logs`: Affiche les logs du système MCP lui-même.
94
- - `pool_stats`: Affiche les statistiques du pool de connexions SSH.
341
+ | Version | Contenu | Snapshot gencodedoc |
342
+ |---------|---------|---------------------|
343
+ | **11.6.1** | Hardening multi-agent: RO transversal, server-to-server dossiers/force, allowed roots anti-symlink, quoting shell/tmux, queue + JSON stores atomiques | — |
344
+ | **11.6.0** | Projets, work sessions, inventory, ssh_authorize_key, RO alias, compact | **#23** (final docs) |
345
+ | 11.4.0 | fleet, infra_audit, groups, retry_all, purge, pool rewrite | #21 |
346
+ | 11.3.0 | Secrets mask, policy shell/seq, port SSH, wait partial | #20 |
347
+ | 10.4–10.0 | file ops, diff, shell, snapshots, notes, guide | #17–19 |
348
+ | 9.x / 8.x | SFTP force, timeouts, interactif, sécu de base | — |
95
349
 
96
- ## 🤝 Contribution
350
+ Détail : **[CHANGELOG.md](./CHANGELOG.md)** · plans historiques : `ROADMAP.md`, `ROADMAP_EXTENDED.md`.
97
351
 
98
- Les contributions sont les bienvenues ! N'hésitez pas à ouvrir une *Issue* pour signaler un bug ou proposer une fonctionnalité, ou une *Pull Request* pour soumettre des modifications.
352
+ ---
99
353
 
100
354
  ## 📄 Licence
101
355
 
102
- Ce projet est sous licence MIT.
356
+ MIT Copyright (c) 2025-2026 Franck (fkom13)
package/apis.js CHANGED
@@ -1,88 +1,44 @@
1
- import fs from 'fs/promises';
2
1
  import path from 'path';
3
2
  import config from './config.js';
3
+ import jsonStore from './atomicJsonStore.js';
4
4
 
5
5
  const APIS_FILE = path.join(config.dataDir, 'apis.json');
6
+ const EMPTY = {};
6
7
 
7
- let apis = {};
8
-
9
- let isInitialized = false;
10
- let initPromise = null;
11
-
12
- // Charger les APIs au démarrage
13
8
  async function loadApis() {
14
- try {
15
- const data = await fs.readFile(APIS_FILE, 'utf-8');
16
- apis = JSON.parse(data);
17
- } catch (err) {
18
- if (err.code === 'ENOENT') {
19
- // Le fichier n'existe pas encore, c'est normal
20
- apis = {};
21
- } else {
22
- console.error("Erreur lors du chargement de apis.json:", err);
23
- }
24
- }
25
- }
26
-
27
- async function ensureInitialized() {
28
- if (isInitialized) return;
29
- if (!initPromise) {
30
- initPromise = loadApis().then(() => { isInitialized = true; });
31
- }
32
- return initPromise;
9
+ return jsonStore.readJson(APIS_FILE, EMPTY);
33
10
  }
34
11
 
35
- // Sauvegarder les APIs
36
- async function saveApis() {
37
- await ensureInitialized();
38
- try {
39
- await fs.writeFile(APIS_FILE, JSON.stringify(apis, null, 2));
40
- } catch (err) {
41
- console.error("Erreur lors de la sauvegarde de apis.json:", err);
42
- }
43
- }
44
-
45
- // Ajouter ou mettre à jour une API
46
12
  async function addApi(alias, apiConfig) {
47
- await ensureInitialized();
48
- apis[alias] = apiConfig;
49
- await saveApis();
13
+ if (!alias || typeof alias !== 'string') throw new Error("L'alias doit être une chaîne non vide.");
14
+ if (!apiConfig.url) throw new Error("L'URL de l'API est obligatoire.");
15
+ try { new URL(apiConfig.url); } catch { throw new Error(`URL invalide: ${apiConfig.url}`); }
16
+
17
+ await jsonStore.updateJson(APIS_FILE, EMPTY, (apis) => {
18
+ apis[alias] = apiConfig;
19
+ return apis;
20
+ });
50
21
  return { success: true, message: `API '${alias}' ajoutée/mise à jour avec succès.` };
51
22
  }
52
23
 
53
- // Lister toutes les APIs
54
24
  async function listApis() {
55
- await ensureInitialized();
56
- return apis;
25
+ return loadApis();
57
26
  }
58
27
 
59
- // Obtenir une API par son alias
60
28
  async function getApi(alias) {
61
- await ensureInitialized();
29
+ const apis = await loadApis();
62
30
  const apiConfig = apis[alias];
63
- if (!apiConfig) {
64
- throw new Error(`L'alias d'API '${alias}' est inconnu.`);
65
- }
31
+ if (!apiConfig) throw new Error(`L'alias d'API '${alias}' est inconnu.`);
66
32
  return apiConfig;
67
33
  }
68
34
 
69
- // Supprimer une API
70
35
  async function removeApi(alias) {
71
- await ensureInitialized();
72
- if (!apis[alias]) {
73
- throw new Error(`L'alias d'API '${alias}' est inconnu.`);
74
- }
75
- delete apis[alias];
76
- await saveApis();
36
+ await jsonStore.updateJson(APIS_FILE, EMPTY, (apis) => {
37
+ if (!apis[alias]) throw new Error(`L'alias d'API '${alias}' est inconnu.`);
38
+ delete apis[alias];
39
+ return apis;
40
+ });
77
41
  return { success: true, message: `API '${alias}' supprimée avec succès.` };
78
42
  }
79
43
 
80
- // Initialiser le module
81
- ensureInitialized();
82
-
83
- export default {
84
- addApi,
85
- listApis,
86
- getApi,
87
- removeApi
88
- };
44
+ export default { addApi, listApis, getApi, removeApi };
@@ -0,0 +1,117 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import crypto from 'crypto';
4
+
5
+ /**
6
+ * AtomicJsonStore — primitive commune pour les petits registres JSON du MCP.
7
+ *
8
+ * Garanties intra-processus :
9
+ * - update(file) sérialise read -> mutate -> write par fichier ;
10
+ * - écriture via temp unique + fsync + rename atomique ;
11
+ * - backup .bak du dernier fichier valide avant remplacement ;
12
+ * - permissions du temp forcées à 0600 (les registres peuvent contenir des secrets) ;
13
+ * - un JSON corrompu n'est plus silencieusement remplacé par {} : tentative .bak,
14
+ * puis erreur explicite si aucune copie valide n'existe.
15
+ *
16
+ * Ce verrou protège les appels concurrents du même process MCP. Pour le SaaS
17
+ * multi-instance, la persistance devra migrer vers DB/transaction ou un lock distribué.
18
+ */
19
+
20
+ const tails = new Map();
21
+
22
+ function clone(value) {
23
+ return value === undefined ? undefined : structuredClone(value);
24
+ }
25
+
26
+ async function withLock(file, fn) {
27
+ const key = path.resolve(file);
28
+ const prev = tails.get(key) || Promise.resolve();
29
+ let release;
30
+ const gate = new Promise(resolve => { release = resolve; });
31
+ const tail = prev.catch(() => {}).then(() => gate);
32
+ tails.set(key, tail);
33
+
34
+ await prev.catch(() => {});
35
+ try {
36
+ return await fn();
37
+ } finally {
38
+ release();
39
+ if (tails.get(key) === tail) tails.delete(key);
40
+ }
41
+ }
42
+
43
+ async function parseFile(file) {
44
+ const text = await fs.readFile(file, 'utf8');
45
+ return JSON.parse(text);
46
+ }
47
+
48
+ async function readJson(file, fallback, { createIfMissing = false } = {}) {
49
+ try {
50
+ return await parseFile(file);
51
+ } catch (err) {
52
+ if (err?.code === 'ENOENT') {
53
+ const value = clone(fallback);
54
+ if (createIfMissing) await writeJsonAtomic(file, value, { backup: false });
55
+ return value;
56
+ }
57
+
58
+ // Parse/I/O error : récupérer la dernière copie saine si possible.
59
+ try {
60
+ return await parseFile(`${file}.bak`);
61
+ } catch {
62
+ const wrapped = new Error(`JSON store illisible: ${file} (${err.message})`);
63
+ wrapped.code = 'JSON_STORE_CORRUPT';
64
+ wrapped.cause = err;
65
+ throw wrapped;
66
+ }
67
+ }
68
+ }
69
+
70
+ async function writeJsonAtomic(file, value, { backup = true } = {}) {
71
+ await fs.mkdir(path.dirname(file), { recursive: true });
72
+
73
+ if (backup) {
74
+ try { await fs.copyFile(file, `${file}.bak`); } catch (e) {
75
+ if (e?.code !== 'ENOENT') throw e;
76
+ }
77
+ }
78
+
79
+ const tmp = `${file}.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}.tmp`;
80
+ let handle = null;
81
+ try {
82
+ handle = await fs.open(tmp, 'w', 0o600);
83
+ await handle.writeFile(JSON.stringify(value, null, 2));
84
+ await handle.sync();
85
+ await handle.close();
86
+ handle = null;
87
+ await fs.rename(tmp, file);
88
+ } catch (err) {
89
+ if (handle) await handle.close().catch(() => {});
90
+ await fs.rm(tmp, { force: true }).catch(() => {});
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ async function ensureJson(file, fallback) {
96
+ return withLock(file, async () => readJson(file, fallback, { createIfMissing: true }));
97
+ }
98
+
99
+ async function updateJson(file, fallback, mutator, options = {}) {
100
+ return withLock(file, async () => {
101
+ const current = await readJson(file, fallback, { createIfMissing: false });
102
+ const next = await mutator(current);
103
+ const value = next === undefined ? current : next;
104
+ await writeJsonAtomic(file, value, options);
105
+ return value;
106
+ });
107
+ }
108
+
109
+ export default {
110
+ withLock,
111
+ readJson,
112
+ writeJsonAtomic,
113
+ ensureJson,
114
+ updateJson
115
+ };
116
+
117
+ export { withLock, readJson, writeJsonAtomic, ensureJson, updateJson };