@fkom13/mcp-sftp-orchestrator 11.3.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/sshPool.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { Client } from 'ssh2';
2
2
  import fs from 'fs/promises';
3
3
  import queue from './queue.js';
4
-
4
+ import utils from './utils.js';
5
5
  import config from './config.js';
6
6
 
7
+ /**
8
+ * Pool de connexions SSH persistantes.
9
+ * v11.4.0 : listeners non empilés sur retry, flag ready fiable, port configurable.
10
+ */
7
11
  class SSHConnectionPool {
8
12
  constructor() {
9
- this.pools = new Map(); // Map<serverAlias, Connection[]>
10
- this.activeConnections = new Map(); // Map<connectionId, {conn, serverAlias, inUse, lastUsed}>
13
+ this.pools = new Map(); // Map<serverAlias, connId[]>
14
+ this.activeConnections = new Map(); // Map<connId, {conn, serverAlias, inUse, lastUsed, ready}>
11
15
  this.config = {
12
16
  maxConnections: config.maxConnectionsPerServer,
13
17
  minConnections: config.minConnectionsPerServer,
@@ -16,24 +20,20 @@ class SSHConnectionPool {
16
20
  connectionTimeout: 20000,
17
21
  retryAttempts: 3
18
22
  };
19
-
20
- // Nettoyage périodique des connexions inactives
21
23
  this.startCleanupInterval();
22
24
  }
23
25
 
24
26
  isConnectionReady(connId) {
25
27
  const connInfo = this.activeConnections.get(connId);
26
- if (!connInfo) return false;
27
-
28
- // Vérifier l'état réel
29
- return connInfo.conn._sock &&
30
- connInfo.conn._sock.readable &&
31
- this.activeConnections.get(connId)?.inUse !== undefined; // Basic check
28
+ if (!connInfo || !connInfo.ready || connInfo.closed) return false;
29
+ const sock = connInfo.conn && connInfo.conn._sock;
30
+ if (!sock) return false;
31
+ // Socket encore ouvert et lisible
32
+ if (sock.destroyed || sock.readyState === 'closed') return false;
33
+ return sock.readable !== false;
32
34
  }
33
35
 
34
- // Obtenir ou créer une connexion
35
36
  async getConnection(serverAlias, serverConfig) {
36
- // Chercher une connexion disponible
37
37
  const pool = this.pools.get(serverAlias) || [];
38
38
 
39
39
  for (const connId of pool) {
@@ -44,92 +44,123 @@ class SSHConnectionPool {
44
44
  queue.log('info', `Réutilisation connexion SSH existante pour ${serverAlias}`);
45
45
  return { id: connId, client: connInfo.conn };
46
46
  }
47
+ // Connexion morte dans le pool → purge
48
+ if (connInfo && !connInfo.inUse && !this.isConnectionReady(connId)) {
49
+ this.removeConnection(connId);
50
+ }
47
51
  }
48
52
 
49
- // Si pas de connexion disponible, en créer une nouvelle
50
- if (pool.length < this.config.maxConnections) {
51
- const newConn = await this.createConnection(serverAlias, serverConfig);
52
- return newConn;
53
+ const currentPool = this.pools.get(serverAlias) || [];
54
+ if (currentPool.length < this.config.maxConnections) {
55
+ return await this.createConnection(serverAlias, serverConfig);
53
56
  }
54
57
 
55
- // Si pool plein, attendre qu'une connexion se libère
56
58
  queue.log('warn', `Pool SSH saturé pour ${serverAlias}, attente...`);
57
59
  return await this.waitForConnection(serverAlias, serverConfig);
58
60
  }
59
61
 
60
- // Créer une nouvelle connexion
61
62
  async createConnection(serverAlias, serverConfig) {
62
- const conn = new Client();
63
- const connId = `${serverAlias}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
64
-
65
- return new Promise((resolve, reject) => {
66
- let retries = 0;
67
-
68
- const tryConnect = async () => {
69
- try {
70
- const config = {
71
- host: serverConfig.host,
72
- port: 22,
73
- username: serverConfig.user,
74
- readyTimeout: this.config.connectionTimeout,
75
- keepaliveInterval: this.config.keepAliveInterval,
76
- keepaliveCountMax: 3
77
- };
78
-
79
- if (serverConfig.keyPath) {
80
- config.privateKey = await fs.readFile(serverConfig.keyPath);
81
- } else if (serverConfig.password) {
82
- config.password = serverConfig.password;
83
- }
63
+ const connId = `${serverAlias}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
64
+ let lastError = null;
84
65
 
85
- conn.on('ready', () => {
86
- queue.log('info', `Nouvelle connexion SSH établie pour ${serverAlias}`);
87
-
88
- // Ajouter au pool
89
- if (!this.pools.has(serverAlias)) {
90
- this.pools.set(serverAlias, []);
91
- }
92
- this.pools.get(serverAlias).push(connId);
93
-
94
- // Enregistrer la connexion
95
- this.activeConnections.set(connId, {
96
- conn,
97
- serverAlias,
98
- inUse: true,
99
- lastUsed: Date.now(),
100
- config: serverConfig
101
- });
102
-
103
- resolve({ id: connId, client: conn });
104
- });
105
-
106
- conn.on('error', (err) => {
107
- if (retries < this.config.retryAttempts) {
108
- retries++;
109
- queue.log('warn', `Tentative ${retries}/${this.config.retryAttempts} de connexion à ${serverAlias}`);
110
- setTimeout(tryConnect, 2000 * retries);
111
- } else {
112
- this.removeConnection(connId);
113
- reject(new Error(`Impossible de se connecter à ${serverAlias}: ${err.message}`));
114
- }
115
- });
116
-
117
- conn.on('close', () => {
118
- this.removeConnection(connId);
119
- queue.log('info', `Connexion SSH fermée pour ${serverAlias}`);
120
- });
121
-
122
- conn.connect(config);
123
- } catch (err) {
124
- reject(err);
66
+ for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
67
+ try {
68
+ const conn = await this._connectOnce(serverAlias, serverConfig, connId);
69
+ return conn;
70
+ } catch (err) {
71
+ lastError = err;
72
+ queue.log('warn', `Tentative ${attempt}/${this.config.retryAttempts} de connexion à ${serverAlias}: ${err.message}`);
73
+ if (attempt < this.config.retryAttempts) {
74
+ await new Promise(r => setTimeout(r, 2000 * attempt));
125
75
  }
76
+ }
77
+ }
78
+
79
+ this.removeConnection(connId);
80
+ throw new Error(`Impossible de se connecter à ${serverAlias}: ${lastError?.message || 'erreur inconnue'}`);
81
+ }
82
+
83
+ /**
84
+ * Une seule tentative = un nouveau Client + listeners uniques (pas d'empilement).
85
+ */
86
+ _connectOnce(serverAlias, serverConfig, connId) {
87
+ return new Promise(async (resolve, reject) => {
88
+ const conn = new Client();
89
+ let settled = false;
90
+
91
+ const fail = (err) => {
92
+ if (settled) return;
93
+ settled = true;
94
+ try { conn.removeAllListeners(); conn.end(); } catch { /* ignore */ }
95
+ reject(err instanceof Error ? err : new Error(String(err)));
96
+ };
97
+
98
+ const succeed = () => {
99
+ if (settled) return;
100
+ settled = true;
101
+ queue.log('info', `Nouvelle connexion SSH établie pour ${serverAlias}`);
102
+
103
+ if (!this.pools.has(serverAlias)) this.pools.set(serverAlias, []);
104
+ const pool = this.pools.get(serverAlias);
105
+ if (!pool.includes(connId)) pool.push(connId);
106
+
107
+ this.activeConnections.set(connId, {
108
+ conn,
109
+ serverAlias,
110
+ inUse: true,
111
+ lastUsed: Date.now(),
112
+ ready: true,
113
+ closed: false,
114
+ config: serverConfig
115
+ });
116
+
117
+ resolve({ id: connId, client: conn });
126
118
  };
127
119
 
128
- tryConnect();
120
+ conn.once('ready', succeed);
121
+ conn.once('error', (err) => fail(err));
122
+ conn.on('close', () => {
123
+ const info = this.activeConnections.get(connId);
124
+ if (info) {
125
+ info.ready = false;
126
+ info.closed = true;
127
+ }
128
+ this.removeConnection(connId);
129
+ queue.log('info', `Connexion SSH fermée pour ${serverAlias}`);
130
+ });
131
+ conn.on('end', () => {
132
+ const info = this.activeConnections.get(connId);
133
+ if (info) {
134
+ info.ready = false;
135
+ info.closed = true;
136
+ }
137
+ });
138
+
139
+ try {
140
+ const connectConfig = {
141
+ host: serverConfig.host,
142
+ port: utils.resolveSshPort(serverConfig),
143
+ username: serverConfig.user,
144
+ readyTimeout: this.config.connectionTimeout,
145
+ keepaliveInterval: this.config.keepAliveInterval,
146
+ keepaliveCountMax: 3
147
+ };
148
+
149
+ if (serverConfig.keyPath) {
150
+ connectConfig.privateKey = await fs.readFile(serverConfig.keyPath);
151
+ } else if (serverConfig.password) {
152
+ connectConfig.password = serverConfig.password;
153
+ } else {
154
+ return fail(new Error(`Aucune méthode d'authentification pour '${serverAlias}'`));
155
+ }
156
+
157
+ conn.connect(connectConfig);
158
+ } catch (err) {
159
+ fail(err);
160
+ }
129
161
  });
130
162
  }
131
163
 
132
- // Libérer une connexion
133
164
  releaseConnection(connId) {
134
165
  const connInfo = this.activeConnections.get(connId);
135
166
  if (connInfo) {
@@ -139,51 +170,41 @@ class SSHConnectionPool {
139
170
  }
140
171
  }
141
172
 
142
- // Fermer une connexion spécifique
143
173
  closeConnection(connId) {
144
174
  const connInfo = this.activeConnections.get(connId);
145
175
  if (connInfo) {
146
176
  try {
177
+ connInfo.ready = false;
147
178
  connInfo.conn.end();
148
- } catch (e) {
149
- // Ignorer les erreurs de fermeture
150
- }
179
+ } catch { /* ignore */ }
151
180
  this.removeConnection(connId);
152
181
  }
153
182
  }
154
183
 
155
- // Retirer une connexion du pool
156
184
  removeConnection(connId) {
157
185
  const connInfo = this.activeConnections.get(connId);
158
186
  if (connInfo) {
159
187
  const pool = this.pools.get(connInfo.serverAlias);
160
188
  if (pool) {
161
189
  const index = pool.indexOf(connId);
162
- if (index > -1) {
163
- pool.splice(index, 1);
164
- }
165
- if (pool.length === 0) {
166
- this.pools.delete(connInfo.serverAlias);
167
- }
190
+ if (index > -1) pool.splice(index, 1);
191
+ if (pool.length === 0) this.pools.delete(connInfo.serverAlias);
168
192
  }
169
193
  this.activeConnections.delete(connId);
170
194
  }
171
195
  }
172
196
 
173
- // Attendre qu'une connexion se libère
174
197
  async waitForConnection(serverAlias, serverConfig, timeout = 30000) {
175
198
  const startTime = Date.now();
176
199
 
177
200
  return new Promise((resolve, reject) => {
178
- const checkInterval = setInterval(async () => {
179
- // Vérifier le timeout
201
+ const checkInterval = setInterval(() => {
180
202
  if (Date.now() - startTime > timeout) {
181
203
  clearInterval(checkInterval);
182
204
  reject(new Error(`Timeout en attendant une connexion pour ${serverAlias}`));
183
205
  return;
184
206
  }
185
207
 
186
- // Essayer d'obtenir une connexion
187
208
  const pool = this.pools.get(serverAlias) || [];
188
209
  for (const connId of pool) {
189
210
  const connInfo = this.activeConnections.get(connId);
@@ -195,36 +216,35 @@ class SSHConnectionPool {
195
216
  return;
196
217
  }
197
218
  }
219
+
220
+ // Slot libre → créer
221
+ if (pool.length < this.config.maxConnections) {
222
+ clearInterval(checkInterval);
223
+ this.createConnection(serverAlias, serverConfig).then(resolve).catch(reject);
224
+ }
198
225
  }, 500);
199
226
  });
200
227
  }
201
228
 
202
- // Nettoyer les connexions inactives
203
229
  startCleanupInterval() {
204
230
  setInterval(() => {
205
231
  const now = Date.now();
206
-
207
- for (const [connId, connInfo] of this.activeConnections) {
208
- // Fermer les connexions inactives depuis trop longtemps
232
+ for (const [connId, connInfo] of [...this.activeConnections.entries()]) {
209
233
  if (!connInfo.inUse && (now - connInfo.lastUsed) > this.config.idleTimeout) {
210
234
  const pool = this.pools.get(connInfo.serverAlias) || [];
211
-
212
- // Garder au moins minConnections
213
235
  if (pool.length > this.config.minConnections) {
214
236
  queue.log('info', `Fermeture connexion inactive: ${connId}`);
215
237
  this.closeConnection(connId);
238
+ continue;
216
239
  }
217
240
  }
218
-
219
- // Vérifier que la connexion est toujours vivante
220
241
  if (!this.isConnectionReady(connId) && !connInfo.inUse) {
221
242
  this.removeConnection(connId);
222
243
  }
223
244
  }
224
- }, 60000); // Vérifier toutes les minutes
245
+ }, 60000);
225
246
  }
226
247
 
227
- // Obtenir les statistiques du pool
228
248
  getStats() {
229
249
  const stats = {
230
250
  totalConnections: this.activeConnections.size,
@@ -253,16 +273,13 @@ class SSHConnectionPool {
253
273
  return stats;
254
274
  }
255
275
 
256
- // Fermer toutes les connexions
257
276
  closeAll() {
258
277
  queue.log('info', 'Fermeture de toutes les connexions SSH...');
259
- for (const connId of this.activeConnections.keys()) {
278
+ for (const connId of [...this.activeConnections.keys()]) {
260
279
  this.closeConnection(connId);
261
280
  }
262
281
  }
263
282
  }
264
283
 
265
- // Instance singleton
266
284
  const sshPool = new SSHConnectionPool();
267
-
268
285
  export default sshPool;
package/sshTrust.js ADDED
@@ -0,0 +1,214 @@
1
+ /**
2
+ * sshTrust.js — Autorisation croisée de clés SSH (pubkey only).
3
+ * Ne copie JAMAIS de clé privée entre serveurs.
4
+ */
5
+ import fs from 'fs/promises';
6
+ import path from 'path';
7
+ import os from 'os';
8
+ import queue from './queue.js';
9
+ import servers from './servers.js';
10
+ import sourceAdapter from './sourceAdapter.js';
11
+ import utils from './utils.js';
12
+
13
+ /**
14
+ * Lit une pubkey depuis:
15
+ * - { type:'string', pubkey }
16
+ * - { type:'local_path', path } (fichier .pub)
17
+ * - { type:'alias', alias } (keyPath du serveur → .pub à côté ou ssh-keygen -y)
18
+ */
19
+ async function resolvePubkey(source) {
20
+ if (!source || !source.type) {
21
+ throw new Error("source requis: {type:'string'|'local_path'|'alias', ...}");
22
+ }
23
+
24
+ if (source.type === 'string') {
25
+ const pk = (source.pubkey || source.key || '').trim();
26
+ if (!pk.startsWith('ssh-') && !pk.startsWith('ecdsa-') && !pk.startsWith('sk-')) {
27
+ throw new Error('pubkey invalide (doit commencer par ssh-ed25519 / ssh-rsa / …)');
28
+ }
29
+ return pk.split('\n')[0].trim();
30
+ }
31
+
32
+ if (source.type === 'local_path') {
33
+ const p = source.path;
34
+ if (!p) throw new Error('source.path requis pour local_path');
35
+ let content = await fs.readFile(p, 'utf-8');
36
+ // Si c'est une privée, refuse
37
+ if (content.includes('PRIVATE KEY')) {
38
+ // tenter .pub voisin
39
+ const pubPath = p.endsWith('.pub') ? p : p + '.pub';
40
+ try {
41
+ content = await fs.readFile(pubPath, 'utf-8');
42
+ } catch {
43
+ throw new Error(`Refus: ${p} semble une clé privée. Fournissez le .pub`);
44
+ }
45
+ }
46
+ const line = content.trim().split('\n').find(l => l.startsWith('ssh-') || l.startsWith('ecdsa-'));
47
+ if (!line) throw new Error(`Aucune pubkey trouvée dans ${p}`);
48
+ return line.trim();
49
+ }
50
+
51
+ if (source.type === 'alias') {
52
+ const sc = await servers.getServer(source.alias);
53
+ if (!sc.keyPath) {
54
+ throw new Error(`Alias '${source.alias}' n'a pas de keyPath (password-only non supporté pour trust).`);
55
+ }
56
+ const pubPath = sc.keyPath.endsWith('.pub') ? sc.keyPath : sc.keyPath + '.pub';
57
+ try {
58
+ const content = await fs.readFile(pubPath, 'utf-8');
59
+ const line = content.trim().split('\n').find(l => l.startsWith('ssh-') || l.startsWith('ecdsa-') || l.startsWith('sk-'));
60
+ if (line) return line.trim();
61
+ } catch {
62
+ // fallback: ssh-keygen -y -f private
63
+ }
64
+ // Générer pubkey depuis privée localement (ne sort pas du MCP host)
65
+ const { execFile } = await import('child_process');
66
+ const { promisify } = await import('util');
67
+ const execFileAsync = promisify(execFile);
68
+ try {
69
+ const { stdout } = await execFileAsync('ssh-keygen', ['-y', '-f', sc.keyPath], {
70
+ timeout: 5000,
71
+ env: { ...process.env, SSH_AUTH_SOCK: '' }
72
+ });
73
+ const line = stdout.trim().split('\n')[0];
74
+ if (!line.startsWith('ssh-') && !line.startsWith('ecdsa-')) {
75
+ throw new Error('ssh-keygen -y n\'a pas renvoyé une pubkey valide');
76
+ }
77
+ return line.trim();
78
+ } catch (e) {
79
+ throw new Error(
80
+ `Impossible d'obtenir la pubkey pour '${source.alias}': ${e.message}. ` +
81
+ `Créez ${pubPath} ou passez source.type=string.`
82
+ );
83
+ }
84
+ }
85
+
86
+ throw new Error(`source.type inconnu: ${source.type}`);
87
+ }
88
+
89
+ function authorizedKeysPath(userHome, explicitPath) {
90
+ if (explicitPath) return explicitPath;
91
+ return path.posix.join(userHome || '~', '.ssh', 'authorized_keys');
92
+ }
93
+
94
+ /**
95
+ * options:
96
+ * target_alias, source?, authorized_keys_path?,
97
+ * source: {type, ...},
98
+ * comment?, dryRun?, force?
99
+ */
100
+ async function authorizeKey(options = {}) {
101
+ const {
102
+ target_alias,
103
+ user = null,
104
+ authorized_keys_path = null,
105
+ source,
106
+ comment = null,
107
+ dryRun = true,
108
+ force = false
109
+ } = options;
110
+
111
+ if (!target_alias) throw new Error('target_alias requis');
112
+ const sc = await servers.getServer(target_alias);
113
+
114
+ const pubkey = await resolvePubkey(source);
115
+ // Normalise: type + body (+ comment optionnel)
116
+ const parts = pubkey.trim().split(/\s+/);
117
+ const typeBody = parts.slice(0, 2).join(' ');
118
+ const existingComment = parts.slice(2).join(' ');
119
+ const lineToAdd = comment
120
+ ? `${typeBody} ${comment}`
121
+ : (existingComment ? `${typeBody} ${existingComment}` : typeBody);
122
+
123
+ // Résoudre home distant
124
+ const homeCmd = user ? `getent passwd ${utils.escapeShellArg(user).replace(/^'|'$/g, '')} 2>/dev/null | cut -d: -f6` : 'echo $HOME';
125
+ // Simpler: use ~ expansion via shell on target for path
126
+ const remoteUser = user || sc.user;
127
+ const akPath = authorized_keys_path
128
+ || `/home/${remoteUser}/.ssh/authorized_keys`;
129
+ // root special case
130
+ const finalAkPath = (remoteUser === 'root' && !authorized_keys_path)
131
+ ? '/root/.ssh/authorized_keys'
132
+ : akPath;
133
+
134
+ let existing = '';
135
+ let existed = false;
136
+ try {
137
+ const r = await sourceAdapter.readFile({ type: 'remote', alias: target_alias, path: finalAkPath });
138
+ existing = r.content.toString('utf-8');
139
+ existed = true;
140
+ } catch {
141
+ existing = '';
142
+ existed = false;
143
+ }
144
+
145
+ const lines = existing.split('\n').map(l => l.trimEnd());
146
+ const pubCore = pubkey.split(' ').slice(0, 2).join(' ');
147
+ const already = lines.some(l => l.includes(pubCore));
148
+
149
+ if (already && !force) {
150
+ return {
151
+ dryRun: !!dryRun,
152
+ applied: false,
153
+ alreadyPresent: true,
154
+ target_alias,
155
+ path: finalAkPath,
156
+ pubkey_fingerprint_hint: pubCore.slice(0, 40) + '…'
157
+ };
158
+ }
159
+
160
+ const newContent = already
161
+ ? existing // force re-add skipped if already
162
+ : (existing.trimEnd() + (existing && !existing.endsWith('\n') ? '\n' : '') + lineToAdd + '\n');
163
+
164
+ if (dryRun) {
165
+ return {
166
+ dryRun: true,
167
+ applied: false,
168
+ alreadyPresent: already,
169
+ wouldAdd: !already,
170
+ target_alias,
171
+ path: finalAkPath,
172
+ linePreview: lineToAdd.slice(0, 80) + (lineToAdd.length > 80 ? '…' : ''),
173
+ existed
174
+ };
175
+ }
176
+
177
+ // Ensure .ssh dir exists via write createDirs
178
+ // Write authorized_keys
179
+ // Also set permissions via a quick exec would be better
180
+ await sourceAdapter.writeFile(
181
+ { type: 'remote', alias: target_alias, path: finalAkPath },
182
+ Buffer.from(newContent, 'utf-8'),
183
+ { createDirs: true }
184
+ );
185
+
186
+ // chmod 700 .ssh / 600 authorized_keys
187
+ const sshDir = path.posix.dirname(finalAkPath);
188
+ const { default: ssh } = await import('./ssh.js');
189
+ const job = queue.addJob({
190
+ type: 'ssh',
191
+ alias: target_alias,
192
+ cmd: `mkdir -p ${utils.escapeShellArg(sshDir)} && chmod 700 ${utils.escapeShellArg(sshDir)} && chmod 600 ${utils.escapeShellArg(finalAkPath)}`,
193
+ timeout: 30,
194
+ skip_policy: true,
195
+ status: 'pending'
196
+ });
197
+ // fire and wait briefly
198
+ await ssh.executeCommand(job.id);
199
+ // small poll
200
+ await new Promise(r => setTimeout(r, 500));
201
+
202
+ queue.log('info', `ssh_authorize_key: pubkey ajoutée sur ${target_alias}:${finalAkPath}`);
203
+
204
+ return {
205
+ dryRun: false,
206
+ applied: true,
207
+ alreadyPresent: already,
208
+ target_alias,
209
+ path: finalAkPath,
210
+ linePreview: lineToAdd.slice(0, 80) + '…'
211
+ };
212
+ }
213
+
214
+ export default { authorizeKey, resolvePubkey };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Metadata MCP agentique centralisée.
3
+ *
4
+ * L'objectif n'est pas de remplacer les garde-fous serveur par des hints :
5
+ * les annotations MCP aident le client/modèle à choisir et confirmer le bon
6
+ * outil, tandis que les policies/guards restent l'autorité réelle.
7
+ */
8
+
9
+ const READ_ONLY = new Set([
10
+ 'system_diagnostics','server_list','server_group_list','fleet_status','infra_audit',
11
+ 'project_list','project_get','project_resolve','project_diff','work_list','server_inventory',
12
+ 'policy_blocklist_list','api_list','api_check','get_system_resources','get_services_status',
13
+ 'check_api_health','get_fail2ban_status','task_queue','task_status','task_history','pool_stats',
14
+ 'queue_stats','task_logs','get_pm2_logs','get_docker_logs','tail_file','help','task_wait',
15
+ 'file_read','diff_files','diff_folders','compare_all_sources','shell_list','snapshot_list',
16
+ 'snapshot_diff','server_note_get','server_note_list','infra_overview','tmux_read','tmux_list',
17
+ 'tunnel_list','guide'
18
+ ]);
19
+
20
+ const DESTRUCTIVE = new Set([
21
+ 'server_remove','server_group_remove','project_remove','work_end','policy_blocklist_remove',
22
+ 'api_remove','task_exec','task_transfer','task_transfer_multi','task_exec_interactive',
23
+ 'task_exec_sequence','task_retry','task_retry_all','task_purge','file_write','file_edit',
24
+ 'shell_exec','shell_close','snapshot_restore','snapshot_delete','server_note_remove',
25
+ 'tmux_exec','tmux_kill','tunnel_close','tunnel_allowlist_remove'
26
+ ]);
27
+
28
+ const IDEMPOTENT_MUTATIONS = new Set([
29
+ 'server_group_set','project_set','server_note_set','policy_blocklist_add',
30
+ 'tunnel_allowlist_add','tunnel_allowlist_remove'
31
+ ]);
32
+
33
+ const CLOSED_WORLD = new Set([
34
+ 'help','guide','policy_blocklist_list','project_list','project_get','project_resolve',
35
+ 'server_group_list','server_note_get','server_note_list','task_queue','task_status',
36
+ 'task_history','queue_stats','task_logs','snapshot_list','snapshot_diff'
37
+ ]);
38
+
39
+ const DESCRIPTION_OVERRIDES = {
40
+ system_diagnostics: 'Diagnostique le processus MCP lui-même (queue, pool SSH, registres, erreurs). Pour l’état réel d’une machine utilisez server_inventory; pour la topologie domaines→ports→services utilisez infra_overview avec alias.',
41
+ infra_audit: 'Contexte de contrôle-plane en un appel: version Orchestrator, registres, groupes, projets, notes, jobs crashés et pool. N’interroge pas profondément les machines. Pour une machine live et ses routes réseau utilisez infra_overview avec alias.',
42
+ infra_overview: 'Cartographie d’infrastructure. Sans alias: vue légère de tout le parc depuis les registres/notes. Avec alias (ou group:/all): découverte live mise en cache des listeners, Docker/Compose, PM2, systemd et Nginx, puis corrélation domaine → proxy → port → conteneur/service.',
43
+ fleet_status: 'Teste en parallèle la joignabilité SSH et quelques ressources de plusieurs serveurs. Choisir cet outil pour “qui est online ?”. Pour le détail d’une machine utilisez server_inventory; pour ses domaines/services exposés utilisez infra_overview.',
44
+ server_inventory: 'Inventaire live léger d’une machine avec cache TTL: ressources, Docker, PM2, HOME et Tailscale. Ne reconstruit pas les routes reverse-proxy; utilisez infra_overview pour domaine → port → service.',
45
+ task_exec: 'Commande distante one-shot. Pour une commande longue utilisez timeout:0 puis task_wait/task_status. Pour conserver cd/export entre appels utilisez shell_create/shell_exec; pour une session terminal survivant au MCP utilisez tmux_*.',
46
+ task_exec_sequence: 'Exécute plusieurs commandes ordonnées sur le même serveur avec résultat par étape. Préférer à plusieurs task_exec lorsque les étapes sont déterministes mais n’ont pas besoin d’un shell persistant.',
47
+ task_exec_interactive: 'Commande distante avec gestion de prompts interactifs connus. Utiliser seulement si la commande attend réellement yes/no/password/choix; sinon préférer task_exec.',
48
+ task_transfer: 'Transfert fichier ou dossier. server_to_server copie directement source_alias → alias. force:false protège une destination existante. Pour une réplication bit-à-bit avec métadonnées/symlinks, vérifier les warnings du résultat.',
49
+ task_transfer_multi: 'Lot de transferts avec patterns glob. À préférer pour plusieurs fichiers indépendants; task_transfer est plus clair pour un fichier/dossier ou un transfert server_to_server.',
50
+ file_edit: 'Édition chirurgicale locale/remote avec protection expectedHash, dryRun et backup. Préférer à file_write pour modifier un fichier existant sans écraser involontairement des changements concurrents.',
51
+ file_write: 'Crée ou remplace un fichier local/remote. Pour un fichier existant, préférer file_edit + expectedHash quand une édition chirurgicale est possible.',
52
+ shell_create: 'Ouvre un shell SSH persistant: cwd et variables survivent entre shell_exec. Pour une tâche détachée survivant à une déconnexion du MCP, préférer tmux_create/tmux_exec.',
53
+ tmux_create: 'Crée une session tmux distante durable. Adapté aux agents/serveurs de dev et tâches longues que l’on doit reprendre après déconnexion.',
54
+ snapshot_create: 'Snapshot CAS dédupliqué de fichiers/dossiers local ou remote pour rollback infra. Ce n’est pas de la mémoire sémantique projet; GenCodeDoc reste la brique Project Intelligence.',
55
+ help: 'Référence compacte des tools et variables Orchestrator. Pour des workflows agentiques guidés, utiliser guide.',
56
+ guide: 'Playbooks agentiques Orchestrator (audit, déploiement, recovery, transferts). Utiliser help pour le schéma/description d’un tool précis.'
57
+ };
58
+
59
+ function annotationsFor(name) {
60
+ const readOnlyHint = READ_ONLY.has(name);
61
+ return {
62
+ readOnlyHint,
63
+ destructiveHint: readOnlyHint ? false : DESTRUCTIVE.has(name),
64
+ idempotentHint: readOnlyHint || IDEMPOTENT_MUTATIONS.has(name),
65
+ openWorldHint: !CLOSED_WORLD.has(name)
66
+ };
67
+ }
68
+
69
+ function enhanceToolConfig(name, config = {}) {
70
+ const annotations = { ...annotationsFor(name), ...(config.annotations || {}) };
71
+ const description = DESCRIPTION_OVERRIDES[name] || config.description;
72
+ return { ...config, description, annotations };
73
+ }
74
+
75
+ export { annotationsFor, enhanceToolConfig, DESCRIPTION_OVERRIDES };
76
+ export default { annotationsFor, enhanceToolConfig, DESCRIPTION_OVERRIDES };