@imenam/mcp-http-gateway 2.0.14 → 2.0.15

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.
@@ -0,0 +1,162 @@
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+
4
+ // --- Exécution sur la machine de l'agent, à la demande d'un MCP du gateway ---
5
+ //
6
+ // Le relais tourne sur la machine de l'AGENT ; un MCP du gateway, lui, n'a aucun moyen
7
+ // d'y lancer quoi que ce soit. Cette convention lui en donne un, à travers les appels
8
+ // d'outils existants :
9
+ //
10
+ // 1. Pour un MCP autorisé, le relais ajoute à chaque appel l'argument réservé
11
+ // `_gateway_exec` : `{ version, cwd, gatewayToken }` — son dossier de lancement, et
12
+ // s'il détient un GATEWAY_TOKEN. Sans cet argument, le MCP sait que le relais
13
+ // n'exécutera rien.
14
+ // 2. Le MCP peut répondre par une demande d'exécution au lieu d'un résultat final :
15
+ // `_meta["gateway/exec"] = { id, steps }`, chaque étape valant `{ command, args, cwd, env }`.
16
+ // 3. Le relais lance les étapes dans l'ordre, sans shell, et s'arrête à la première qui
17
+ // échoue. `cwd` est résolu par rapport à son dossier de lancement. Dans `args` et les
18
+ // valeurs de `env`, il remplace `{{GATEWAY_URL}}` et `{{GATEWAY_TOKEN}}`.
19
+ // 4. Il rappelle le même outil avec `_gateway_exec.continuation = { id, results }`, et
20
+ // recommence tant que le MCP renvoie une demande. L'agent ne voit que le résultat final.
21
+ //
22
+ // GATEWAY_EXEC_ROUTES Routes, dans le gateway, des MCP autorisés à exécuter, séparées
23
+ // par des virgules (ex. `/github`). C'est l'interrupteur : vide,
24
+ // aucun MCP n'exécute. Seul un appel vers une route listée porte
25
+ // `_gateway_exec`, et une demande d'exécution venue d'une autre
26
+ // route fait échouer l'appel sans rien lancer.
27
+ //
28
+ // Chaque commande lancée laisse une ligne sur stderr, avec ses marqueurs non remplacés :
29
+ // ce que le gateway fait exécuter doit être lisible dans le journal de l'agent, sans que
30
+ // le jeton y figure.
31
+
32
+ export const EXEC_ARG = '_gateway_exec';
33
+ export const EXEC_META = 'gateway/exec';
34
+ export const EXEC_VERSION = 1;
35
+
36
+ /** Nombre maximal d'allers-retours pour un même appel d'outil. */
37
+ export const MAX_EXEC_ROUNDS = 50;
38
+
39
+ /** Taille maximale conservée de la sortie standard et de la sortie d'erreur d'une étape. */
40
+ const MAX_OUTPUT_BYTES = 1024 * 1024;
41
+
42
+ /** Forme canonique d'une route : `/github/` et `/github` désignent le même MCP. */
43
+ export function normalizeRoute(route) {
44
+ return typeof route === 'string' ? route.trim().replace(/\/+$/, '') : '';
45
+ }
46
+
47
+ /**
48
+ * Lit la directive GATEWAY_EXEC_ROUTES.
49
+ * Rend les routes autorisées, et les entrées invalides (qui ne commencent pas par `/`).
50
+ */
51
+ export function parseExecRoutes(raw) {
52
+ const entries = (raw ?? '').split(',').map((e) => e.trim()).filter(Boolean);
53
+ const invalid = entries.filter((e) => !/^\/\S*$/.test(e) || normalizeRoute(e) === '');
54
+ const routes = new Set(entries.filter((e) => !invalid.includes(e)).map(normalizeRoute));
55
+ return { routes, invalid };
56
+ }
57
+
58
+ /** L'argument réservé qui annonce au MCP que ce relais sait exécuter. */
59
+ export function execAnnouncement({ cwd, gatewayToken }) {
60
+ return { version: EXEC_VERSION, cwd, gatewayToken: Boolean(gatewayToken) };
61
+ }
62
+
63
+ /** La demande d'exécution portée par un résultat d'outil, ou null. */
64
+ export function execRequestOf(result) {
65
+ const request = result?._meta?.[EXEC_META];
66
+ return request && typeof request === 'object' ? request : null;
67
+ }
68
+
69
+ function substitute(value, values) {
70
+ return value.replace(/\{\{(GATEWAY_URL|GATEWAY_TOKEN)\}\}/g, (_, key) => {
71
+ const replacement = values[key];
72
+ if (replacement === undefined) {
73
+ throw new Error(`le MCP utilise {{${key}}}, mais ce relais n'en a pas (variable ${key} non définie)`);
74
+ }
75
+ return replacement;
76
+ });
77
+ }
78
+
79
+ function validateStep(step, index) {
80
+ const ok = step && typeof step.command === 'string' && step.command !== ''
81
+ && Array.isArray(step.args) && step.args.every((a) => typeof a === 'string')
82
+ && (step.cwd === undefined || typeof step.cwd === 'string')
83
+ && (step.env === undefined || (typeof step.env === 'object'
84
+ && Object.values(step.env).every((v) => typeof v === 'string')));
85
+ if (!ok) throw new Error(`demande d'exécution invalide : étape ${index + 1} mal formée`);
86
+ }
87
+
88
+ function collect(stream, sink) {
89
+ stream.on('data', (chunk) => {
90
+ if (sink.bytes >= MAX_OUTPUT_BYTES) {
91
+ sink.truncated = true;
92
+ return;
93
+ }
94
+ const part = chunk.subarray(0, MAX_OUTPUT_BYTES - sink.bytes);
95
+ sink.chunks.push(part);
96
+ sink.bytes += part.length;
97
+ if (part.length < chunk.length) sink.truncated = true;
98
+ });
99
+ }
100
+
101
+ function outputText(sink) {
102
+ const text = Buffer.concat(sink.chunks).toString('utf8');
103
+ return sink.truncated ? `${text}\n[sortie tronquée à ${MAX_OUTPUT_BYTES} octets]` : text;
104
+ }
105
+
106
+ function runStep(step, { root, values }) {
107
+ const args = step.args.map((a) => substitute(a, values));
108
+ const env = Object.fromEntries(
109
+ Object.entries(step.env ?? {}).map(([k, v]) => [k, substitute(v, values)])
110
+ );
111
+ const cwd = path.resolve(root, step.cwd ?? '.');
112
+
113
+ return new Promise((resolve) => {
114
+ const out = { chunks: [], bytes: 0, truncated: false };
115
+ const err = { chunks: [], bytes: 0, truncated: false };
116
+ let settled = false;
117
+ const settle = (result) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ resolve(result);
121
+ };
122
+
123
+ const child = spawn(step.command, args, {
124
+ cwd,
125
+ env: { ...process.env, ...env },
126
+ stdio: ['ignore', 'pipe', 'pipe'],
127
+ windowsHide: true,
128
+ });
129
+ collect(child.stdout, out);
130
+ collect(child.stderr, err);
131
+ child.on('error', (e) =>
132
+ settle({ code: null, stdout: outputText(out), stderr: outputText(err), error: `lancement impossible : ${e.message}` })
133
+ );
134
+ child.on('close', (code) =>
135
+ settle({ code, stdout: outputText(out), stderr: outputText(err) })
136
+ );
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Exécute les étapes d'une demande, dans l'ordre, jusqu'à la première qui échoue.
142
+ * Rend un résultat par étape exécutée.
143
+ */
144
+ export async function runExecRequest(request, { root, gatewayUrl, gatewayToken, label = '' }) {
145
+ if (typeof request.id !== 'string' || !Array.isArray(request.steps) || request.steps.length === 0) {
146
+ throw new Error("demande d'exécution invalide : identifiant ou étapes manquants");
147
+ }
148
+ request.steps.forEach(validateStep);
149
+
150
+ const values = { GATEWAY_URL: gatewayUrl, GATEWAY_TOKEN: gatewayToken };
151
+ const results = [];
152
+ for (const step of request.steps) {
153
+ console.error(
154
+ `[mcp-mode] Exécution demandée${label ? ` par ${label}` : ''} : ${step.command} ${step.args.join(' ')}` +
155
+ ` (dossier : ${path.resolve(root, step.cwd ?? '.')})`
156
+ );
157
+ const result = await runStep(step, { root, values });
158
+ results.push(result);
159
+ if (result.code !== 0) break;
160
+ }
161
+ return results;
162
+ }
@@ -0,0 +1,348 @@
1
+ import crypto from 'crypto';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { normalizeRoute } from './gateway-exec.js';
7
+
8
+ // --- Veille : réveiller l'agent quand un MCP du gateway a du nouveau ---
9
+ //
10
+ // Le gateway ne sait que répondre : il ne pousse rien vers l'agent. Un processus veilleur,
11
+ // lancé sur la machine de l'AGENT par l'outil `Monitor` de son hôte, appelle donc en boucle
12
+ // un outil d'attente du MCP, à travers l'API REST du gateway. Chaque appel reste ouvert
13
+ // jusqu'à ce que le MCP ait un événement, ou jusqu'à expiration ; chaque événement devient
14
+ // une ligne sur la sortie du veilleur, et `Monitor` réveille l'agent à chaque ligne.
15
+ //
16
+ // La convention, côté MCP :
17
+ //
18
+ // 1. Un outil d'activation déclare dans son schéma la propriété réservée `_gateway_watch`.
19
+ // Le relais la retire du schéma montré à l'agent et la remplit à chaque appel :
20
+ // `{ version }`. Sans elle, le MCP sait qu'aucun relais ne veillera pour lui.
21
+ // 2. L'outil d'activation répond par une spécification de veille :
22
+ // `_meta["gateway/watch"] = { tool, arguments, cursor, description }` — l'outil d'attente,
23
+ // ses arguments fixes, le point de reprise de départ, et le libellé du `Monitor`.
24
+ // 3. Le relais dépose, sur la machine de l'agent, un fichier de veille en 0600 : l'adresse
25
+ // et le jeton du gateway, la route, l'outil, ses arguments et le point de reprise. Il
26
+ // rend à l'agent l'appel `Monitor` qui lance le veilleur sur ce fichier. Le jeton ne
27
+ // figure jamais dans la commande, donc jamais dans la conversation.
28
+ // 4. Le veilleur appelle l'outil d'attente avec `cursor` et `timeout_seconds`. L'outil
29
+ // répond `_meta["gateway/events"] = { cursor, lines }` : une ligne par événement, et le
30
+ // point de reprise suivant, que le veilleur enregistre dans le fichier de veille.
31
+ //
32
+ // Le fichier de veille est propre au relais qui l'écrit : il est retiré à l'arrêt du relais,
33
+ // ou, si le relais a été tué, par le prochain relais qui démarre.
34
+ //
35
+ // GATEWAY_WATCH_DIR Dossier des fichiers de veille (défaut : ~/.mcp-http-gateway/watch).
36
+
37
+ export const WATCH_ARG = '_gateway_watch';
38
+ export const WATCH_META = 'gateway/watch';
39
+ export const EVENTS_META = 'gateway/events';
40
+ export const WATCH_VERSION = 1;
41
+
42
+ /** Durée maximale d'un `Monitor` côté hôte. */
43
+ export const MONITOR_TIMEOUT_MS = 1_800_000;
44
+
45
+ /** Attente demandée à l'outil : sous les 60 s qu'accorde le gateway à un appel d'outil. */
46
+ const WAIT_SECONDS = 50;
47
+ const REQUEST_TIMEOUT_MS = (WAIT_SECONDS + 40) * 1000;
48
+ const BACKOFF_MIN_MS = 1_000;
49
+ const BACKOFF_MAX_MS = 30_000;
50
+
51
+ /** Échecs consécutifs au-delà desquels l'agent est prévenu que la veille est interrompue. */
52
+ const FAILURES_BEFORE_NOTICE = 3;
53
+
54
+ const SERVER_SCRIPT = path.join(path.dirname(fileURLToPath(import.meta.url)), 'server.js');
55
+
56
+ export function watchDir() {
57
+ const override = process.env.GATEWAY_WATCH_DIR?.trim();
58
+ return override ? path.resolve(override) : path.join(os.homedir(), '.mcp-http-gateway', 'watch');
59
+ }
60
+
61
+ /** L'outil déclare-t-il la propriété réservée qui l'inscrit à la veille ? */
62
+ export function declaresWatch(inputSchema) {
63
+ return Boolean(inputSchema?.properties && WATCH_ARG in inputSchema.properties);
64
+ }
65
+
66
+ /** L'argument réservé qui annonce au MCP que ce relais veillera pour lui. */
67
+ export function watchAnnouncement() {
68
+ return { version: WATCH_VERSION };
69
+ }
70
+
71
+ /** La spécification de veille portée par un résultat d'outil, ou null. */
72
+ export function watchSpecOf(result) {
73
+ const spec = result?._meta?.[WATCH_META];
74
+ return spec && typeof spec === 'object' ? spec : null;
75
+ }
76
+
77
+ function validateSpec(spec) {
78
+ const ok = typeof spec.tool === 'string' && spec.tool !== ''
79
+ && typeof spec.cursor === 'string'
80
+ && (spec.arguments === undefined || (typeof spec.arguments === 'object' && !Array.isArray(spec.arguments)))
81
+ && (spec.description === undefined || typeof spec.description === 'string');
82
+ if (!ok) throw new Error('spécification de veille invalide : outil, point de reprise ou arguments mal formés');
83
+ }
84
+
85
+ function watchFilePath(route, tool) {
86
+ const digest = crypto.createHash('sha256').update(`${route}\n${tool}\n${process.pid}`).digest('hex').slice(0, 16);
87
+ return path.join(watchDir(), `${digest}.json`);
88
+ }
89
+
90
+ function presencePath(file) {
91
+ return file.replace(/\.json$/, '.watcher.json');
92
+ }
93
+
94
+ /** Écrit un fichier lisible par le seul utilisateur courant, sans jamais l'exposer à moitié écrit. */
95
+ function writePrivate(file, value) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
97
+ const temporary = `${file}.${process.pid}.tmp`;
98
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
99
+ fs.chmodSync(temporary, 0o600);
100
+ fs.renameSync(temporary, file);
101
+ }
102
+
103
+ function readJson(file) {
104
+ try {
105
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function processExists(pid) {
112
+ try {
113
+ process.kill(pid, 0);
114
+ return true;
115
+ } catch (err) {
116
+ return err.code === 'EPERM';
117
+ }
118
+ }
119
+
120
+ /** Veilleur vivant qui suit ce fichier de veille, ou null. */
121
+ function activeWatcher(file) {
122
+ const presence = readJson(presencePath(file));
123
+ return presence && typeof presence.pid === 'number' && processExists(presence.pid) ? presence : null;
124
+ }
125
+
126
+ /** Commande du veilleur : le même Node et le même paquet que ce relais. */
127
+ export function watchCommand(file) {
128
+ const quote = (value) => `"${value.replace(/\\/g, '/')}"`;
129
+ return `${quote(process.execPath)} ${quote(SERVER_SCRIPT)} --watch ${quote(file)}`;
130
+ }
131
+
132
+ /** Fichiers de veille écrits par ce relais, retirés à son arrêt. */
133
+ const written = new Set();
134
+
135
+ /**
136
+ * Dépose le fichier de veille d'une spécification et rend le résultat à montrer à l'agent :
137
+ * l'appel `Monitor` à faire, ou le rappel qu'un veilleur tourne déjà.
138
+ */
139
+ export function armWatch(spec, { route, gatewayUrl, gatewayToken }) {
140
+ validateSpec(spec);
141
+ const canonicalRoute = normalizeRoute(route);
142
+ const file = watchFilePath(canonicalRoute, spec.tool);
143
+ const command = watchCommand(file);
144
+ const description = spec.description ?? `Événements de ${canonicalRoute}`;
145
+
146
+ const watcher = activeWatcher(file);
147
+ if (watcher) {
148
+ // Le veilleur en place tient son propre point de reprise : réécrire le fichier le ferait
149
+ // repartir de celui du MCP et sauter les événements survenus entre-temps.
150
+ return {
151
+ content: [{
152
+ type: 'text',
153
+ text: [
154
+ `Les notifications de ${canonicalRoute} sont déjà actives (veilleur armé depuis ${watcher.since}).`,
155
+ 'Rien à faire : continuez, les événements vous interrompront d’eux-mêmes.',
156
+ 'N’armez pas de second veilleur. Seulement si l’hôte vient de signaler que le veilleur a expiré',
157
+ `ou s’est arrêté, réarmez-le avec le même appel Monitor (command ${command}, timeout_ms ${MONITOR_TIMEOUT_MS}).`,
158
+ ].join('\n'),
159
+ }],
160
+ };
161
+ }
162
+
163
+ writePrivate(file, {
164
+ version: WATCH_VERSION,
165
+ gatewayUrl,
166
+ gatewayToken: gatewayToken ?? null,
167
+ route: canonicalRoute,
168
+ tool: spec.tool,
169
+ arguments: spec.arguments ?? {},
170
+ cursor: spec.cursor,
171
+ relay_pid: process.pid,
172
+ written_at: new Date().toISOString(),
173
+ });
174
+ written.add(file);
175
+ pruneOrphanWatches();
176
+
177
+ return {
178
+ content: [{
179
+ type: 'text',
180
+ text: [
181
+ `Notifications activées pour ${canonicalRoute}.`,
182
+ '',
183
+ 'Dernière étape — appelez maintenant l’outil Monitor avec exactement ces arguments :',
184
+ ` command: ${command}`,
185
+ ` description: ${description}`,
186
+ ` timeout_ms: ${MONITOR_TIMEOUT_MS}`,
187
+ '',
188
+ 'Tant que ce veilleur n’est pas armé, aucun événement ne vous parviendra. Une fois armé,',
189
+ 'continuez normalement : chaque événement arrive de lui-même, une ligne par événement.',
190
+ '',
191
+ 'L’hôte arrête tout Monitor au bout de 30 minutes au plus. À chaque expiration signalée,',
192
+ 'rappelez aussitôt Monitor avec les mêmes arguments, sans rappeler l’outil d’activation :',
193
+ 'le veilleur reprend là où il s’était arrêté.',
194
+ ].join('\n'),
195
+ }],
196
+ };
197
+ }
198
+
199
+ /** Retire les fichiers de veille de ce relais. Synchrone : appelable sur `exit`. */
200
+ export function removeOwnWatches() {
201
+ for (const file of written) {
202
+ fs.rmSync(file, { force: true });
203
+ fs.rmSync(presencePath(file), { force: true });
204
+ }
205
+ written.clear();
206
+ }
207
+
208
+ /**
209
+ * Retire les fichiers de veille dont le relais n'existe plus, et les présences dont le
210
+ * veilleur n'existe plus : ils restent après un processus tué sans pouvoir faire son ménage.
211
+ */
212
+ export function pruneOrphanWatches() {
213
+ const dir = watchDir();
214
+ if (!fs.existsSync(dir)) return;
215
+ for (const entry of fs.readdirSync(dir)) {
216
+ const file = path.join(dir, entry);
217
+ if (entry.endsWith('.watcher.json')) {
218
+ const presence = readJson(file);
219
+ if (typeof presence?.pid !== 'number' || !processExists(presence.pid)) fs.rmSync(file, { force: true });
220
+ continue;
221
+ }
222
+ if (!entry.endsWith('.json')) continue;
223
+ const watch = readJson(file);
224
+ if (typeof watch?.relay_pid !== 'number' || !processExists(watch.relay_pid)) {
225
+ fs.rmSync(file, { force: true });
226
+ fs.rmSync(presencePath(file), { force: true });
227
+ }
228
+ }
229
+ }
230
+
231
+ // --- Le veilleur (server.js --watch <fichier>) ---
232
+
233
+ class WatchError extends Error {
234
+ /** `lasting` : l'erreur ne se résoudra pas d'elle-même, l'agent doit être prévenu tout de suite. */
235
+ constructor(message, lasting = false) {
236
+ super(message);
237
+ this.lasting = lasting;
238
+ }
239
+ }
240
+
241
+ function readWatchFile(file) {
242
+ const watch = readJson(file);
243
+ if (!watch || typeof watch.gatewayUrl !== 'string' || typeof watch.route !== 'string'
244
+ || typeof watch.tool !== 'string' || typeof watch.cursor !== 'string') {
245
+ throw new Error(
246
+ `fichier de veille illisible ou absent : ${file}. Rappelez l’outil qui active les notifications, ` +
247
+ 'puis armez le veilleur avec la commande qu’il renvoie.'
248
+ );
249
+ }
250
+ return watch;
251
+ }
252
+
253
+ /** Enregistre le point de reprise, sans recréer un fichier que le relais a retiré. */
254
+ function saveCursor(file, cursor) {
255
+ const watch = readJson(file);
256
+ if (watch) writePrivate(file, { ...watch, cursor });
257
+ }
258
+
259
+ async function poll(watch, cursor) {
260
+ const label = `${watch.route}/${watch.tool}`;
261
+ const headers = { 'Content-Type': 'application/json' };
262
+ if (watch.gatewayToken) headers.Authorization = `Bearer ${watch.gatewayToken}`;
263
+
264
+ let res;
265
+ try {
266
+ res = await fetch(`${watch.gatewayUrl}${watch.route}/tools/${encodeURIComponent(watch.tool)}`, {
267
+ method: 'POST',
268
+ headers,
269
+ body: JSON.stringify({ ...watch.arguments, cursor, timeout_seconds: WAIT_SECONDS }),
270
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
271
+ });
272
+ } catch (err) {
273
+ throw new WatchError(`gateway injoignable (${err.cause?.message ?? err.message})`);
274
+ }
275
+ if (res.status === 401 || res.status === 403) {
276
+ throw new WatchError(`accès refusé par le gateway (HTTP ${res.status}) : vérifiez le GATEWAY_TOKEN du relais`, true);
277
+ }
278
+ if (!res.ok) throw new WatchError(`le gateway répond HTTP ${res.status} à ${label}`);
279
+
280
+ const result = await res.json();
281
+ if (result.isError) {
282
+ const detail = result.content?.map((c) => c.text).filter(Boolean).join(' ') || 'erreur sans détail';
283
+ throw new WatchError(`${label} a échoué : ${detail}`);
284
+ }
285
+ const events = result._meta?.[EVENTS_META];
286
+ if (!events || typeof events.cursor !== 'string' || !Array.isArray(events.lines)) {
287
+ throw new WatchError(`${label} ne renvoie pas _meta["${EVENTS_META}"] : ce n’est pas un outil de veille`, true);
288
+ }
289
+ return events;
290
+ }
291
+
292
+ /**
293
+ * Boucle du veilleur. Une ligne sur stdout = un événement ; journal et erreurs sur stderr.
294
+ * Une panne se traduit par un réessai avec temporisation croissante, jamais par une sortie :
295
+ * un veilleur mort est un agent qui se croit prévenu et ne l'est plus. Une panne qui dure,
296
+ * ou qui ne se résoudra pas seule, est annoncée une fois sur stdout.
297
+ */
298
+ export async function runWatchMode(file, { write = (line) => process.stdout.write(`${line}\n`), shouldStop } = {}) {
299
+ if (!file) {
300
+ throw new Error('--watch attend le chemin du fichier de veille donné par l’outil qui active les notifications.');
301
+ }
302
+ const watch = readWatchFile(file);
303
+ const label = `${watch.route}/${watch.tool}`;
304
+
305
+ const presence = presencePath(file);
306
+ writePrivate(presence, { pid: process.pid, since: new Date().toISOString() });
307
+ const unmark = () => {
308
+ if (readJson(presence)?.pid === process.pid) fs.rmSync(presence, { force: true });
309
+ };
310
+ process.on('exit', unmark);
311
+ for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => process.exit(0));
312
+ console.error(`[gateway-watch] Veille de ${label} via ${watch.gatewayUrl}`);
313
+
314
+ let cursor = watch.cursor;
315
+ let backoff = BACKOFF_MIN_MS;
316
+ let failures = 0;
317
+ let announced = false;
318
+ try {
319
+ while (!shouldStop?.()) {
320
+ try {
321
+ const events = await poll(watch, cursor);
322
+ if (announced) write(`[gateway-watch] ${label} : la veille a repris.`);
323
+ failures = 0;
324
+ announced = false;
325
+ backoff = BACKOFF_MIN_MS;
326
+ for (const line of events.lines) write(String(line).replace(/\s+/g, ' ').trim());
327
+ if (events.cursor !== cursor) {
328
+ cursor = events.cursor;
329
+ saveCursor(file, cursor);
330
+ }
331
+ } catch (err) {
332
+ failures++;
333
+ console.error(`[gateway-watch] ${err.message} — nouvel essai dans ${Math.round(backoff / 1000)} s`);
334
+ if (!announced && (err.lasting || failures >= FAILURES_BEFORE_NOTICE)) {
335
+ write(
336
+ `[gateway-watch] ${label} : la veille échoue (${err.message}). Elle réessaie seule ; ` +
337
+ 'aucune notification n’arrivera tant que ça dure.'
338
+ );
339
+ announced = true;
340
+ }
341
+ await new Promise((resolve) => setTimeout(resolve, backoff));
342
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
343
+ }
344
+ }
345
+ } finally {
346
+ unmark();
347
+ }
348
+ }
package/http-relay.js ADDED
@@ -0,0 +1,93 @@
1
+ import http from 'http';
2
+
3
+ // Directive gateway déclarée par un MCP dans son bloc `env` : le MCP écoute en HTTP
4
+ // sur 127.0.0.1:<port>, et le gateway lui relaie tel quel `<route>/http/*`.
5
+ export const HTTP_RELAY_PORT_KEY = 'MCP_HTTP_RELAY_PORT';
6
+
7
+ // En-têtes propres à un saut de connexion : jamais retransmis par un relais.
8
+ const HOP_BY_HOP = new Set([
9
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
10
+ 'te', 'trailer', 'transfer-encoding', 'upgrade', 'host',
11
+ ]);
12
+
13
+ /**
14
+ * Lit la directive MCP_HTTP_RELAY_PORT d'un MCP.
15
+ * Absente → `{ port: null, valid: true }` ; présente mais hors 1–65535 → `valid: false`.
16
+ */
17
+ export function parseRelayPort(raw) {
18
+ if (raw === undefined || raw === '') return { port: null, valid: true };
19
+ const port = Number(raw);
20
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return { port: null, valid: false };
21
+ return { port, valid: true };
22
+ }
23
+
24
+ export function relayPrefix(route) {
25
+ return `${route}/http`;
26
+ }
27
+
28
+ /**
29
+ * Indique si un chemin appartient à l'un des préfixes de relais. Ces requêtes
30
+ * gardent leur corps intact : il est transmis au MCP sans être lu ni décodé.
31
+ */
32
+ export function isRelayPath(prefixes, reqPath) {
33
+ for (const prefix of prefixes) {
34
+ if (reqPath === prefix || reqPath.startsWith(`${prefix}/`)) return true;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ function forwardedRequestHeaders(headers) {
40
+ const out = {};
41
+ for (const [key, value] of Object.entries(headers)) {
42
+ if (HOP_BY_HOP.has(key)) continue;
43
+ // Le jeton qui autorise l'accès au gateway ne concerne pas le MCP.
44
+ if (key === 'authorization') continue;
45
+ // Le gateway a déjà répondu « 100 Continue » au client.
46
+ if (key === 'expect') continue;
47
+ out[key] = value;
48
+ }
49
+ return out;
50
+ }
51
+
52
+ function forwardedResponseHeaders(headers) {
53
+ const out = {};
54
+ for (const [key, value] of Object.entries(headers)) {
55
+ if (HOP_BY_HOP.has(key)) continue;
56
+ out[key] = value;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ /**
62
+ * Middleware express, monté sur `<route>/http` : relaie la requête vers
63
+ * http://127.0.0.1:<port><reste du chemin>, corps et réponse transmis en flux.
64
+ */
65
+ export function createHttpRelay({ name, port }) {
66
+ return (req, res) => {
67
+ const upstream = http.request(
68
+ {
69
+ host: '127.0.0.1',
70
+ port,
71
+ method: req.method,
72
+ path: req.url,
73
+ headers: forwardedRequestHeaders(req.headers),
74
+ },
75
+ (upstreamRes) => {
76
+ res.writeHead(upstreamRes.statusCode ?? 502, forwardedResponseHeaders(upstreamRes.headers));
77
+ upstreamRes.pipe(res);
78
+ }
79
+ );
80
+
81
+ upstream.on('error', (err) => {
82
+ console.error(`[${name}] HTTP relay error (127.0.0.1:${port}):`, err.message);
83
+ if (!res.headersSent) {
84
+ res.status(502).json({ error: `HTTP relay to "${name}" failed: ${err.message}` });
85
+ } else {
86
+ res.destroy(err);
87
+ }
88
+ });
89
+
90
+ res.on('close', () => upstream.destroy());
91
+ req.pipe(upstream);
92
+ };
93
+ }