@imenam/mcp-http-gateway 2.0.15 → 2.0.16

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
@@ -52,6 +52,7 @@ GATEWAY_CONFIG_DIR=./config
52
52
  |---|---|---|
53
53
  | `GATEWAY_PORT` | `3500` | Port HTTP du gateway |
54
54
  | `GATEWAY_CONFIG_DIR` | `./config` (relatif au cwd) | Dossier contenant `mcp-servers.json` |
55
+ | `GATEWAY_ADMIN_TOKEN` | — | Active `POST /admin/reload` et en est le jeton. Absent, la route n'existe pas. Voir [Rechargement à chaud](#rechargement-à-chaud). |
55
56
  | `GATEWAY_BODY_LIMIT` | `32mb` | Taille maximale du corps d'une requête. Le défaut d'express (100 ko) ne suffit pas dès qu'un appel d'outil porte le contenu d'un fichier en base64 ; au-delà de cette limite, la réponse est un `413`. Ne s'applique pas aux routes de [relais HTTP](#relais-http-mcp_http_relay_port). |
56
57
 
57
58
  ### `mcp-servers.json`
@@ -105,6 +106,47 @@ npm start
105
106
 
106
107
  Le gateway connecte chaque MCP configuré et démarre le serveur HTTP. Les MCPs en échec au démarrage sont ignorés sans bloquer les autres.
107
108
 
109
+ ## Rechargement à chaud
110
+
111
+ Avec `GATEWAY_ADMIN_TOKEN` défini, le gateway relit `mcp-servers.json` sur demande, sans redémarrer :
112
+
113
+ ```bash
114
+ curl -X POST -H "X-Gateway-Admin-Token: $GATEWAY_ADMIN_TOKEN" http://127.0.0.1:3500/admin/reload
115
+ ```
116
+
117
+ Chaque MCP est comparé à sa configuration en service :
118
+
119
+ | Changement | Effet |
120
+ |---|---|
121
+ | MCP retiré du fichier | son processus est arrêté, sa route disparaît |
122
+ | MCP ajouté, ou en échec jusque-là | il est lancé |
123
+ | `command`, `args` ou une variable de `env` modifiés | **seul ce MCP** est relancé |
124
+ | `description` ou `EXPOSE_AT_AGENT_STARTUP` seuls modifiés | mis à jour sans relance |
125
+ | rien | rien |
126
+
127
+ Les autres MCP ne sont pas touchés : leurs processus, leurs sessions et leurs appels en cours continuent.
128
+
129
+ La réponse donne le résultat MCP par MCP :
130
+
131
+ ```json
132
+ {
133
+ "revision": "5ecfab70.3",
134
+ "results": [
135
+ { "name": "documentor", "action": "unchanged", "ok": true },
136
+ { "name": "github", "action": "restarted", "ok": true },
137
+ { "name": "scraper", "action": "started", "ok": false, "error": "MCP error -32000: Connection closed" }
138
+ ]
139
+ }
140
+ ```
141
+
142
+ `action` vaut `started`, `restarted`, `stopped`, `updated` ou `unchanged`. Un MCP qui ne démarre pas est signalé par `ok: false` et reste hors service ; le rechargement suivant tente de le relancer. Un fichier illisible est refusé en `400`, et rien n'est modifié.
143
+
144
+ Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'authentification OAuth du gateway.
145
+
146
+ ### Révision
147
+
148
+ Chaque réponse du gateway porte l'en-tête `X-Gateway-Revision`. Sa valeur change à chaque démarrage du gateway et à chaque rechargement qui modifie quelque chose. Le [mode MCP](#mode-mcp) s'en sert pour mettre à jour les outils de l'agent.
149
+
108
150
  ## Routes
109
151
 
110
152
  ### `GET /mcps`
@@ -651,13 +693,15 @@ points d'entrée par lesquels un agent atteint le gateway :
651
693
  - **bridge stdio** (`server.js --mcp`, cas local) — au démarrage, le bridge interroge le
652
694
  serveur (`GET /startup-exposition`), publie les outils reçus et route leurs appels vers
653
695
  la bonne route. Le bridge ne connaît pas la config : il ne fait que transmettre ce que le
654
- serveur décide.
696
+ serveur décide. Quand une réponse du gateway porte une nouvelle
697
+ [révision](#révision), le bridge relit `GET /startup-exposition` et envoie
698
+ `notifications/tools/list_changed` à l'agent.
655
699
 
656
700
  ---
657
701
 
658
702
  ## Ajouter un MCP
659
703
 
660
704
  1. Ajouter l'entrée dans `config/mcp-servers.json` avec `MCP_SERVER_ROUTE` dans `env`
661
- 2. Redémarrer le gateway
705
+ 2. Appeler `POST /admin/reload` (voir [Rechargement à chaud](#rechargement-à-chaud)), ou redémarrer le gateway
662
706
 
663
707
  Aucune modification du MCP n'est nécessaire.
package/mcp-endpoint.js CHANGED
@@ -39,13 +39,14 @@ const tools = [
39
39
  },
40
40
  ];
41
41
 
42
- function buildMcpServer({ registry, proxyTools = [] }) {
42
+ function buildMcpServer(registry) {
43
43
  const server = new Server(
44
44
  { name: 'mcp-http-gateway-remote', version: '1.0.0' },
45
45
  { capabilities: { tools: {} } }
46
46
  );
47
47
 
48
48
  // Outils des MCP pré-exposés en ALL : publiés tels quels, appelables sans call_tool.
49
+ const { proxyTools } = registry;
49
50
  const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
50
51
  const allTools = [...tools, ...proxyTools.map(toolDefinition)];
51
52
 
@@ -57,7 +58,7 @@ function buildMcpServer({ registry, proxyTools = [] }) {
57
58
  try {
58
59
  const proxy = proxyIndex.get(name);
59
60
  if (proxy) {
60
- const entry = registry.find(m => m.route === proxy.route);
61
+ const entry = registry.byRoute(proxy.route);
61
62
  if (!entry) {
62
63
  return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
63
64
  }
@@ -65,14 +66,14 @@ function buildMcpServer({ registry, proxyTools = [] }) {
65
66
  }
66
67
 
67
68
  if (name === 'list_mcps') {
68
- const text = registry
69
+ const text = registry.entries
69
70
  .map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
70
71
  .join('\n');
71
72
  return { content: [{ type: 'text', text }] };
72
73
  }
73
74
 
74
75
  if (name === 'list_tools') {
75
- const entry = registry.find(m => m.route === args.route);
76
+ const entry = registry.byRoute(args.route);
76
77
  if (!entry) {
77
78
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
78
79
  }
@@ -81,7 +82,7 @@ function buildMcpServer({ registry, proxyTools = [] }) {
81
82
  }
82
83
 
83
84
  if (name === 'call_tool') {
84
- const entry = registry.find(m => m.route === args.route);
85
+ const entry = registry.byRoute(args.route);
85
86
  if (!entry) {
86
87
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
87
88
  }
@@ -123,7 +124,7 @@ function buildDnsRebindingConfig() {
123
124
  return { allowedHosts, allowedOrigins };
124
125
  }
125
126
 
126
- export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
127
+ export function registerMcpEndpoint(app, registry) {
127
128
  app.get('/.well-known/oauth-protected-resource', (req, res) => {
128
129
  res.json(getProtectedResourceMetadata());
129
130
  });
@@ -138,7 +139,7 @@ export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
138
139
  // Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
139
140
  // et l'endpoint supporte le scale-out multi-instances.
140
141
  app.post('/mcp', async (req, res) => {
141
- const server = buildMcpServer({ registry, proxyTools });
142
+ const server = buildMcpServer(registry);
142
143
  const transport = new StreamableHTTPServerTransport({
143
144
  sessionIdGenerator: undefined,
144
145
  enableJsonResponse: true,
package/mcp-mode.js CHANGED
@@ -47,9 +47,11 @@ function gatewayHeaders(base) {
47
47
  }
48
48
 
49
49
  /** Le seul point de sortie HTTP du relais : tout appel au gateway passe par ici. */
50
- function gatewayFetch(path, init = {}) {
50
+ async function gatewayFetch(path, init = {}) {
51
51
  const headers = gatewayHeaders(init.headers);
52
- return fetch(`${GATEWAY_URL}${path}`, headers ? { ...init, headers } : init);
52
+ const res = await fetch(`${GATEWAY_URL}${path}`, headers ? { ...init, headers } : init);
53
+ noteRevision(res.headers.get('x-gateway-revision'));
54
+ return res;
53
55
  }
54
56
 
55
57
  /** Un refus d'authentification mérite mieux qu'un `HTTP 401` nu. */
@@ -594,7 +596,7 @@ async function toolSchema(route, toolName) {
594
596
  // pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
595
597
  // sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
596
598
  // façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
597
- // OAuth, etc.), on continue sans pré-exposition.
599
+ // OAuth, etc.), on continue sans pré-exposition. Rend null si les outils n'ont pas pu être lus.
598
600
  async function fetchProxyTools() {
599
601
  try {
600
602
  const res = await gatewayFetch('/startup-exposition');
@@ -603,23 +605,51 @@ async function fetchProxyTools() {
603
605
  // Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
604
606
  // maintenant évite de chercher plus tard. Sans le token, évidemment.
605
607
  if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
606
- return [];
608
+ return null;
607
609
  }
608
610
  const { proxyTools } = await res.json();
609
611
  return proxyTools ?? [];
610
612
  } catch {
611
- return [];
613
+ return null;
612
614
  }
613
615
  }
614
616
 
615
- const proxyTools = await fetchProxyTools();
616
- if (proxyTools.length) {
617
- console.error(`[mcp-mode] ${proxyTools.length} proxy tool(s) exposed directly from gateway`);
617
+ // --- Suivi des changements de configuration du gateway ---
618
+ //
619
+ // Le gateway renvoie la révision de son état dans l'en-tête `X-Gateway-Revision` de chaque
620
+ // réponse ; elle change quand un MCP est ajouté, retiré ou relancé, et à chaque démarrage du
621
+ // gateway. Dès qu'une réponse en porte une nouvelle, le relais relit les outils pré-exposés,
622
+ // oublie les schémas mis en cache et notifie l'agent par `notifications/tools/list_changed`.
623
+
624
+ let knownRevision = null;
625
+ let refreshQueue = Promise.resolve();
626
+
627
+ function noteRevision(revision) {
628
+ if (!revision || revision === knownRevision) return;
629
+ const previous = knownRevision;
630
+ knownRevision = revision;
631
+ if (previous === null) return;
632
+ refreshQueue = refreshQueue.then(() => refreshTools(previous)).catch((err) => {
633
+ console.error(`[mcp-mode] Rafraîchissement des outils impossible : ${err.message}`);
634
+ });
635
+ }
636
+
637
+ async function refreshTools(previousRevision) {
638
+ routeSchemas.clear();
639
+ const next = await fetchProxyTools();
640
+ if (next === null) {
641
+ // Révision remise à l'ancienne valeur : la prochaine réponse du gateway relancera la lecture.
642
+ knownRevision = previousRevision;
643
+ return;
644
+ }
645
+ setProxyTools(next);
646
+ await server.sendToolListChanged();
647
+ console.error(`[mcp-mode] Configuration du gateway modifiée (révision ${knownRevision}) : ${next.length} outil(s) pré-exposé(s)`);
618
648
  }
619
649
 
620
650
  const server = new Server(
621
651
  { name: 'mcp-http-gateway-mcp', version: '1.0.0' },
622
- { capabilities: { tools: {} } }
652
+ { capabilities: { tools: { listChanged: true } } }
623
653
  );
624
654
 
625
655
  const tools = [
@@ -654,15 +684,26 @@ const tools = [
654
684
  },
655
685
  ];
656
686
 
657
- const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
658
- const allTools = [
659
- ...tools,
660
- ...proxyTools.map(({ name, description, inputSchema }) => ({
661
- name,
662
- description,
663
- inputSchema: schemaForAgent(inputSchema),
664
- })),
665
- ];
687
+ let proxyIndex = new Map();
688
+ let allTools = tools;
689
+
690
+ function setProxyTools(proxyTools) {
691
+ proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
692
+ allTools = [
693
+ ...tools,
694
+ ...proxyTools.map(({ name, description, inputSchema }) => ({
695
+ name,
696
+ description,
697
+ inputSchema: schemaForAgent(inputSchema),
698
+ })),
699
+ ];
700
+ }
701
+
702
+ const initialProxyTools = (await fetchProxyTools()) ?? [];
703
+ setProxyTools(initialProxyTools);
704
+ if (initialProxyTools.length) {
705
+ console.error(`[mcp-mode] ${initialProxyTools.length} proxy tool(s) exposed directly from gateway`);
706
+ }
666
707
 
667
708
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
668
709
 
@@ -0,0 +1,234 @@
1
+ import crypto from 'crypto';
2
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
3
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
4
+ import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
5
+ import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix } from './http-relay.js';
6
+
7
+ // --- Registre des MCP hébergés par le gateway ---
8
+ //
9
+ // Le registre est la seule source de vérité sur les MCP en service : les routes REST, le
10
+ // relais HTTP, l'endpoint /mcp et la pré-exposition le consultent à chaque requête.
11
+ //
12
+ // `apply(servers)` aligne le registre sur une configuration `mcpServers` :
13
+ // - MCP absent de la configuration → son processus est arrêté ;
14
+ // - MCP nouveau, ou pas en service → il est lancé ;
15
+ // - command, args ou env modifiés → seul ce MCP est relancé ;
16
+ // - description ou EXPOSE_AT_AGENT_STARTUP seuls modifiés → mis à jour sans relance.
17
+ // Les autres MCP ne sont pas touchés.
18
+ //
19
+ // Chaque changement effectif incrémente la révision, que le gateway renvoie dans l'en-tête
20
+ // `X-Gateway-Revision` : le relais stdio (mcp-mode.js) s'en sert pour rafraîchir ses outils.
21
+
22
+ export const REVISION_HEADER = 'X-Gateway-Revision';
23
+
24
+ // Variables système minimales transmises aux MCPs enfants.
25
+ // On exclut délibérément toutes les variables propres au gateway
26
+ // (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
27
+ const CHILD_ENV_WHITELIST = new Set([
28
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
29
+ 'LANG', 'LC_ALL', 'LC_CTYPE',
30
+ 'TEMP', 'TMP', 'TMPDIR',
31
+ 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
32
+ 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
33
+ 'COMSPEC', 'PATHEXT', 'USERNAME',
34
+ 'PROGRAMFILES', 'NODE_PATH',
35
+ ]);
36
+
37
+ // Directives lues par le gateway seul : les modifier ne justifie pas de relancer le MCP.
38
+ const GATEWAY_ONLY_KEYS = new Set(['EXPOSE_AT_AGENT_STARTUP']);
39
+
40
+ function buildChildEnv(cfgEnv = {}) {
41
+ const safeEnv = Object.fromEntries(
42
+ Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
43
+ );
44
+ return { ...safeEnv, ...cfgEnv };
45
+ }
46
+
47
+ async function connectMcp(name, cfg) {
48
+ const transport = new StdioClientTransport({
49
+ command: cfg.command,
50
+ args: cfg.args ?? [],
51
+ env: buildChildEnv(cfg.env),
52
+ });
53
+ const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
54
+ await client.connect(transport);
55
+ console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
56
+ return client;
57
+ }
58
+
59
+ /** Empreinte de ce qui définit le processus enfant : la changer impose de le relancer. */
60
+ function processKey(cfg) {
61
+ const env = Object.entries(cfg.env ?? {})
62
+ .filter(([key]) => !GATEWAY_ONLY_KEYS.has(key))
63
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
64
+ return JSON.stringify([cfg.command, cfg.args ?? [], env]);
65
+ }
66
+
67
+ /**
68
+ * Lit les directives gateway d'un MCP. Lève une erreur si le MCP ne peut pas être servi.
69
+ */
70
+ function readDirectives(name, cfg) {
71
+ const route = cfg.env?.MCP_SERVER_ROUTE;
72
+ if (!route) throw new Error('no MCP_SERVER_ROUTE defined');
73
+
74
+ const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
75
+ const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
76
+ if (!valid) {
77
+ console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
78
+ }
79
+
80
+ const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
81
+ const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
82
+ if (!relayPortValid) {
83
+ throw new Error(`invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
84
+ }
85
+
86
+ return { route, exposeAtStartup, relayPort, description: cfg.description ?? null };
87
+ }
88
+
89
+ export class McpRegistry {
90
+ /** Entrées en service, dans l'ordre de la configuration. */
91
+ entries = [];
92
+ proxyTools = [];
93
+ /** Préfixes `<route>/http` des MCP qui déclarent un relais HTTP. */
94
+ relayPrefixes = new Set();
95
+
96
+ #bootId = crypto.randomUUID().slice(0, 8);
97
+ #generation = 0;
98
+ #queue = Promise.resolve();
99
+ #onToolsLoaded;
100
+
101
+ /**
102
+ * @param {{ onToolsLoaded?: (name: string, tools: object[]) => void }} options
103
+ * `onToolsLoaded` est appelé à chaque lancement d'un MCP, avec ses outils.
104
+ */
105
+ constructor({ onToolsLoaded } = {}) {
106
+ this.#onToolsLoaded = onToolsLoaded ?? (() => {});
107
+ }
108
+
109
+ /** Identifiant de l'état servi : change à chaque démarrage du gateway et à chaque changement effectif. */
110
+ get revision() {
111
+ return `${this.#bootId}.${this.#generation}`;
112
+ }
113
+
114
+ byRoute(route) {
115
+ return this.entries.find((entry) => entry.route === route) ?? null;
116
+ }
117
+
118
+ byName(name) {
119
+ return this.entries.find((entry) => entry.name === name) ?? null;
120
+ }
121
+
122
+ /**
123
+ * Aligne le registre sur `servers` (le bloc `mcpServers` de la configuration).
124
+ * Les appels sont sérialisés. Rend une ligne par MCP concerné :
125
+ * `{ name, action, ok, error? }`, `action` ∈ started | restarted | stopped | updated | unchanged.
126
+ */
127
+ apply(servers) {
128
+ const run = this.#queue.then(() => this.#apply(servers));
129
+ this.#queue = run.catch(() => {});
130
+ return run;
131
+ }
132
+
133
+ async closeAll() {
134
+ await Promise.allSettled(this.entries.map((entry) => entry.client.close()));
135
+ }
136
+
137
+ async #apply(servers) {
138
+ const results = [];
139
+ const wanted = new Map(Object.entries(servers ?? {}));
140
+ const restarting = new Set();
141
+ let changed = false;
142
+
143
+ // Arrêts d'abord : un MCP relancé ou une route reprise ne doivent pas croiser l'ancien processus.
144
+ for (const entry of [...this.entries]) {
145
+ const cfg = wanted.get(entry.name);
146
+ if (cfg && processKey(cfg) === entry.processKey) continue;
147
+ await this.#stop(entry);
148
+ changed = true;
149
+ if (cfg) restarting.add(entry.name);
150
+ else results.push({ name: entry.name, action: 'stopped', ok: true });
151
+ }
152
+
153
+ for (const [name, cfg] of wanted) {
154
+ const current = this.byName(name);
155
+
156
+ if (current) {
157
+ const { description, exposeAtStartup } = readDirectives(name, cfg);
158
+ const updated = current.description !== description || current.exposeAtStartup !== exposeAtStartup;
159
+ current.description = description;
160
+ current.exposeAtStartup = exposeAtStartup;
161
+ if (updated) changed = true;
162
+ results.push({ name, action: updated ? 'updated' : 'unchanged', ok: true });
163
+ continue;
164
+ }
165
+
166
+ const action = restarting.has(name) ? 'restarted' : 'started';
167
+ try {
168
+ await this.#start(name, cfg);
169
+ changed = true;
170
+ results.push({ name, action, ok: true });
171
+ } catch (err) {
172
+ console.error(`[gateway] Failed to start "${name}": ${err.message}`);
173
+ results.push({ name, action, ok: false, error: err.message });
174
+ }
175
+ }
176
+
177
+ // Ordre de la configuration, conservé pour list_mcps, /mcps et la pré-exposition.
178
+ const order = [...wanted.keys()];
179
+ this.entries.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
180
+
181
+ if (changed) {
182
+ this.#generation += 1;
183
+ this.#refreshDerived();
184
+ }
185
+ return results;
186
+ }
187
+
188
+ async #start(name, cfg) {
189
+ const directives = readDirectives(name, cfg);
190
+ if (this.byRoute(directives.route)) {
191
+ throw new Error(`route ${directives.route} already served by "${this.byRoute(directives.route).name}"`);
192
+ }
193
+ const client = await connectMcp(name, cfg);
194
+ let tools;
195
+ try {
196
+ ({ tools } = await client.listTools());
197
+ } catch (err) {
198
+ await client.close().catch(() => {});
199
+ throw err;
200
+ }
201
+ this.entries.push({ name, ...directives, tools, client, processKey: processKey(cfg) });
202
+ if (directives.relayPort) {
203
+ console.log(`[gateway] HTTP relay: ${relayPrefix(directives.route)}/* → 127.0.0.1:${directives.relayPort} (${name})`);
204
+ }
205
+ this.#onToolsLoaded(name, tools);
206
+ }
207
+
208
+ async #stop(entry) {
209
+ this.entries = this.entries.filter((e) => e !== entry);
210
+ try {
211
+ await entry.client.close();
212
+ } catch (err) {
213
+ console.error(`[gateway] Error while closing "${entry.name}":`, err.message);
214
+ }
215
+ console.log(`[gateway] Stopped: ${entry.name}`);
216
+ }
217
+
218
+ #refreshDerived() {
219
+ this.relayPrefixes = new Set(
220
+ this.entries.filter((e) => e.relayPort).map((e) => relayPrefix(e.route))
221
+ );
222
+ this.proxyTools = buildProxyTools(this.entries);
223
+
224
+ const preExposed = this.entries.filter((e) => e.exposeAtStartup);
225
+ if (preExposed.length) {
226
+ console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map((e) => e.name).join(', ')} — ${this.proxyTools.length} tool(s) published directly in tools/list`);
227
+ }
228
+ const overlong = overlongProxyNames(this.proxyTools);
229
+ if (overlong.length) {
230
+ console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
231
+ }
232
+ console.log(`[gateway] Revision ${this.revision} — ${this.entries.length} MCP(s) in service`);
233
+ }
234
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-http-gateway",
3
- "version": "2.0.15",
3
+ "version": "2.0.16",
4
4
  "type": "module",
5
5
  "description": "HTTP gateway that exposes MCP servers as REST routes — bridge between MCP and any HTTP-capable agent",
6
6
  "main": "server.js",
@@ -17,7 +17,8 @@
17
17
  "startup-exposition.js",
18
18
  "http-relay.js",
19
19
  "gateway-exec.js",
20
- "gateway-watch.js"
20
+ "gateway-watch.js",
21
+ "mcp-registry.js"
21
22
  ],
22
23
  "scripts": {
23
24
  "start": "node server.js",
package/server.js CHANGED
@@ -13,17 +13,16 @@ if (process.argv.includes('--watch')) {
13
13
  // Process stays alive: the MCP transport keeps stdin open until the client disconnects
14
14
  }
15
15
 
16
+ import crypto from 'crypto';
16
17
  import { readFileSync } from 'fs';
17
18
  import path from 'path';
18
19
  import express from 'express';
19
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
20
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
21
20
  import { initDb, getOrComputeToolEmbedding, getAllToolEmbeddings } from './embeddings/db.js';
22
21
  import { getEmbedding, cosineSimilarity } from './embeddings/vectorizer.js';
23
22
  import { registerMcpEndpoint } from './mcp-endpoint.js';
24
- import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
25
23
  import { createMcpTokenVerifier } from './auth.js';
26
- import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix, isRelayPath, createHttpRelay } from './http-relay.js';
24
+ import { relayPrefix, isRelayPath, createHttpRelay } from './http-relay.js';
25
+ import { McpRegistry, REVISION_HEADER } from './mcp-registry.js';
27
26
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
28
27
  import { rateLimit } from 'express-rate-limit';
29
28
 
@@ -38,65 +37,61 @@ function normalizeCfg(cfg) {
38
37
  return { ...cfg, command, args, env };
39
38
  }
40
39
 
41
- // Variables système minimales transmises aux MCPs enfants.
42
- // On exclut délibérément toutes les variables propres au gateway
43
- // (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
44
- const CHILD_ENV_WHITELIST = new Set([
45
- 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
46
- 'LANG', 'LC_ALL', 'LC_CTYPE',
47
- 'TEMP', 'TMP', 'TMPDIR',
48
- 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
49
- 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
50
- 'COMSPEC', 'PATHEXT', 'USERNAME',
51
- 'PROGRAMFILES', 'NODE_PATH',
52
- ]);
53
-
54
- function buildChildEnv(cfgEnv = {}) {
55
- const safeEnv = Object.fromEntries(
56
- Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
57
- );
58
- return { ...safeEnv, ...cfgEnv };
59
- }
40
+ // --- Routes des MCP ---
41
+
42
+ /**
43
+ * Routes `<route>/tools`, `<route>/tools/:toolName` et `<route>/http/*` de chaque MCP,
44
+ * résolues sur le registre à chaque requête.
45
+ */
46
+ function mcpRoutes(registry) {
47
+ return async (req, res, next) => {
48
+ for (const entry of registry.entries) {
49
+ const { route, name, client } = entry;
50
+ if (req.path !== route && !req.path.startsWith(`${route}/`)) continue;
51
+ const rest = req.path.slice(route.length);
52
+
53
+ if (entry.relayPort && (rest === '/http' || rest.startsWith('/http/'))) {
54
+ req.url = req.url.slice(relayPrefix(route).length) || '/';
55
+ return createHttpRelay({ name, port: entry.relayPort })(req, res);
56
+ }
60
57
 
61
- // --- MCP client ---
58
+ if (req.method === 'GET' && rest === '/tools') {
59
+ try {
60
+ const { tools } = await client.listTools();
61
+ return res.json(tools);
62
+ } catch (err) {
63
+ console.error(`[${name}] listTools error:`, err.message);
64
+ return res.status(500).json({ error: err.message });
65
+ }
66
+ }
62
67
 
63
- async function connectMcp(name, cfg) {
64
- const transport = new StdioClientTransport({
65
- command: cfg.command,
66
- args: cfg.args ?? [],
67
- env: buildChildEnv(cfg.env),
68
- });
69
- const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
70
- await client.connect(transport);
71
- console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
72
- return client;
68
+ const toolMatch = /^\/tools\/([^/]+)$/.exec(rest);
69
+ if (req.method === 'POST' && toolMatch) {
70
+ const toolName = decodeURIComponent(toolMatch[1]);
71
+ try {
72
+ const result = await client.callTool({ name: toolName, arguments: req.body ?? {} });
73
+ return res.json(result);
74
+ } catch (err) {
75
+ console.error(`[${name}] callTool(${toolName}) error:`, err.message);
76
+ return res.status(500).json({ error: err.message });
77
+ }
78
+ }
79
+ }
80
+ return next();
81
+ };
73
82
  }
74
83
 
75
- // --- Routes ---
76
-
77
- function registerMcpRoutes(app, route, client, name) {
78
- app.get(`${route}/tools`, async (req, res) => {
79
- try {
80
- const { tools } = await client.listTools();
81
- res.json(tools);
82
- } catch (err) {
83
- console.error(`[${name}] listTools error:`, err.message);
84
- res.status(500).json({ error: err.message });
85
- }
86
- });
84
+ function readServers(configPath) {
85
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
86
+ return config.mcpServers ?? {};
87
+ }
87
88
 
88
- app.post(`${route}/tools/:toolName`, async (req, res) => {
89
- try {
90
- const result = await client.callTool({
91
- name: req.params.toolName,
92
- arguments: req.body ?? {},
93
- });
94
- res.json(result);
95
- } catch (err) {
96
- console.error(`[${name}] callTool(${req.params.toolName}) error:`, err.message);
97
- res.status(500).json({ error: err.message });
98
- }
99
- });
89
+ /** Comparaison à temps constant du jeton d'administration présenté. */
90
+ function adminTokenMatches(presented, expected) {
91
+ if (typeof presented !== 'string') return false;
92
+ const a = crypto.createHash('sha256').update(presented).digest();
93
+ const b = crypto.createHash('sha256').update(expected).digest();
94
+ return crypto.timingSafeEqual(a, b);
100
95
  }
101
96
 
102
97
  // --- Bootstrap ---
@@ -113,8 +108,18 @@ async function main() {
113
108
  ? path.resolve(process.env.GATEWAY_CONFIG_DIR)
114
109
  : path.join(process.cwd(), 'config');
115
110
  const configPath = path.join(configDir, 'mcp-servers.json');
116
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
117
- const servers = config.mcpServers ?? {};
111
+ const servers = readServers(configPath);
112
+
113
+ const registry = new McpRegistry({
114
+ onToolsLoaded: (name, tools) => {
115
+ for (const tool of tools) {
116
+ if (!tool.description) continue;
117
+ getOrComputeToolEmbedding(name, tool, getEmbedding).catch(err =>
118
+ console.error(`[gateway] Embedding error for ${name}::${tool.name}:`, err.message)
119
+ );
120
+ }
121
+ },
122
+ });
118
123
 
119
124
  const app = express();
120
125
  if (process.env.TRUST_PROXY) {
@@ -127,10 +132,39 @@ async function main() {
127
132
  // qui repartirait sinon en « 413 Payload Too Large » sans explication.
128
133
  const BODY_LIMIT = process.env.GATEWAY_BODY_LIMIT ?? '32mb';
129
134
  const jsonParser = express.json({ type: '*/*', limit: BODY_LIMIT });
135
+ app.use((req, res, next) => {
136
+ res.setHeader(REVISION_HEADER, registry.revision);
137
+ next();
138
+ });
139
+
140
+ // Rechargement de la configuration, réservé au détenteur de GATEWAY_ADMIN_TOKEN.
141
+ // Seuls les MCP dont la configuration a changé sont arrêtés, lancés ou relancés.
142
+ const ADMIN_TOKEN = process.env.GATEWAY_ADMIN_TOKEN?.trim() || null;
143
+ if (ADMIN_TOKEN) {
144
+ app.post('/admin/reload', async (req, res) => {
145
+ if (!adminTokenMatches(req.get('X-Gateway-Admin-Token'), ADMIN_TOKEN)) {
146
+ return res.status(401).json({ error: 'Unauthorized' });
147
+ }
148
+ let nextServers;
149
+ try {
150
+ nextServers = readServers(configPath);
151
+ } catch (err) {
152
+ console.error('[gateway] Reload refused, unreadable configuration:', err.message);
153
+ return res.status(400).json({ error: `Unreadable configuration ${configPath}: ${err.message}` });
154
+ }
155
+ const results = await registry.apply(nextServers);
156
+ const summary = results
157
+ .filter(r => r.action !== 'unchanged')
158
+ .map(r => `${r.name}: ${r.action}${r.ok ? '' : ' (failed)'}`);
159
+ console.log(`[gateway] Reload — ${summary.length ? summary.join(', ') : 'no change'}`);
160
+ return res.json({ revision: registry.revision, results });
161
+ });
162
+ console.log('[gateway] Admin endpoint enabled: POST /admin/reload');
163
+ }
164
+
130
165
  // Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
131
- const relayPrefixes = new Set();
132
166
  app.use((req, res, next) =>
133
- isRelayPath(relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
167
+ isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
134
168
  );
135
169
 
136
170
  const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
@@ -196,53 +230,8 @@ async function main() {
196
230
  const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
197
231
  initDb(dbPath);
198
232
 
199
- const clients = [];
200
- const registry = []; // { name, route, description, tools, exposeAtStartup }
201
-
202
- for (const [name, cfg] of Object.entries(servers)) {
203
- const route = cfg.env?.MCP_SERVER_ROUTE;
204
- if (!route) {
205
- console.warn(`[gateway] Skipping "${name}": no MCP_SERVER_ROUTE defined`);
206
- continue;
207
- }
208
-
209
- // Directive gateway déclarée par MCP dans son bloc `env` (comme MCP_SERVER_ROUTE) :
210
- // pré-expose ou non ses outils à l'agent. Absente → MCP non pré-exposé.
211
- const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
212
- const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
213
- if (!valid) {
214
- console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
215
- }
216
-
217
- const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
218
- const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
219
- if (!relayPortValid) {
220
- console.error(`[gateway] Skipping "${name}": invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
221
- continue;
222
- }
223
-
224
- try {
225
- const client = await connectMcp(name, cfg);
226
- const { tools } = await client.listTools();
227
- clients.push(client);
228
- registry.push({ name, route, description: cfg.description ?? null, tools, client, exposeAtStartup, relayPort });
229
- registerMcpRoutes(app, route, client, name);
230
- if (relayPort) {
231
- const prefix = relayPrefix(route);
232
- relayPrefixes.add(prefix);
233
- app.use(prefix, createHttpRelay({ name, port: relayPort }));
234
- console.log(`[gateway] HTTP relay: ${prefix}/* → 127.0.0.1:${relayPort} (${name})`);
235
- }
236
- for (const tool of tools) {
237
- if (!tool.description) continue;
238
- getOrComputeToolEmbedding(name, tool, getEmbedding).catch(err =>
239
- console.error(`[gateway] Embedding error for ${name}::${tool.name}:`, err.message)
240
- );
241
- }
242
- } catch (err) {
243
- console.error(`[gateway] Failed to connect "${name}":`, err.message);
244
- }
245
- }
233
+ await registry.apply(servers);
234
+ app.use(mcpRoutes(registry));
246
235
 
247
236
  app.get('/tools/search', searchLimiter, async (req, res) => {
248
237
  const q = req.query.q?.trim();
@@ -251,12 +240,13 @@ async function main() {
251
240
  try {
252
241
  const queryEmbedding = await getEmbedding(q);
253
242
  const results = getAllToolEmbeddings()
243
+ .filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
254
244
  .map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
255
245
  .sort((a, b) => b.score - a.score)
256
246
  .slice(0, limit)
257
247
  .map(({ mcp_name, tool_name, description, score }) => ({
258
248
  mcp_name,
259
- mcp_description: registry.find(r => r.name === mcp_name)?.description ?? null,
249
+ mcp_description: registry.byName(mcp_name)?.description ?? null,
260
250
  tool_name,
261
251
  description,
262
252
  score,
@@ -270,7 +260,7 @@ async function main() {
270
260
 
271
261
  app.get('/mcp-definitions', (req, res) => {
272
262
  res.json(
273
- registry.map(({ name, route, description, tools }) => ({
263
+ registry.entries.map(({ name, route, description, tools }) => ({
274
264
  id: name,
275
265
  route,
276
266
  description,
@@ -283,19 +273,8 @@ async function main() {
283
273
  // `tools/list`. Exposés en HTTP pour que le bridge stdio (server.js --mcp /
284
274
  // mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
285
275
  // la config vit uniquement côté serveur, le bridge se contente de transmettre.
286
- const proxyTools = buildProxyTools(registry);
287
-
288
- const preExposed = registry.filter(r => r.exposeAtStartup);
289
- if (preExposed.length) {
290
- console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map(r => r.name).join(', ')} — ${proxyTools.length} tool(s) published directly in tools/list`);
291
- }
292
- const overlong = overlongProxyNames(proxyTools);
293
- if (overlong.length) {
294
- console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
295
- }
296
-
297
276
  app.get('/startup-exposition', (req, res) => {
298
- res.json({ proxyTools });
277
+ res.json({ proxyTools: registry.proxyTools, revision: registry.revision });
299
278
  });
300
279
 
301
280
  app.get('/mcps', (req, res) => {
@@ -307,7 +286,7 @@ async function main() {
307
286
  '',
308
287
  '## Serveurs',
309
288
  '',
310
- ...registry.map(({ name, route, description, relayPort }) =>
289
+ ...registry.entries.map(({ name, route, description, relayPort }) =>
311
290
  `### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
312
291
  (relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
313
292
  ),
@@ -315,7 +294,7 @@ async function main() {
315
294
  res.type('text/plain').send(lines.join('\n'));
316
295
  });
317
296
 
318
- registerMcpEndpoint(app, { clients, registry, proxyTools });
297
+ registerMcpEndpoint(app, registry);
319
298
 
320
299
  const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
321
300
  console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
@@ -328,7 +307,7 @@ async function main() {
328
307
 
329
308
  const shutdown = async () => {
330
309
  console.log('[gateway] Shutting down...');
331
- await Promise.allSettled(clients.map(c => c.close()));
310
+ await registry.closeAll();
332
311
  process.exit(0);
333
312
  };
334
313