@imenam/mcp-http-gateway 2.0.15 → 2.0.19

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,8 @@ 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). |
56
+ | `GATEWAY_SCOPE_SECRET` | — | Impose un périmètre d'accès signé sur chaque requête. Absent, toute requête voit tous les MCP et tous les outils. Voir [Périmètre d'accès](#périmètre-daccès). |
55
57
  | `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
58
 
57
59
  ### `mcp-servers.json`
@@ -105,6 +107,81 @@ npm start
105
107
 
106
108
  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
109
 
110
+ ## Rechargement à chaud
111
+
112
+ Avec `GATEWAY_ADMIN_TOKEN` défini, le gateway relit `mcp-servers.json` sur demande, sans redémarrer :
113
+
114
+ ```bash
115
+ curl -X POST -H "X-Gateway-Admin-Token: $GATEWAY_ADMIN_TOKEN" http://127.0.0.1:3500/admin/reload
116
+ ```
117
+
118
+ Chaque MCP est comparé à sa configuration en service :
119
+
120
+ | Changement | Effet |
121
+ |---|---|
122
+ | MCP retiré du fichier | son processus est arrêté, sa route disparaît |
123
+ | MCP ajouté, ou en échec jusque-là | il est lancé |
124
+ | `command`, `args` ou une variable de `env` modifiés | **seul ce MCP** est relancé |
125
+ | `description` ou `EXPOSE_AT_AGENT_STARTUP` seuls modifiés | mis à jour sans relance |
126
+ | rien | rien |
127
+
128
+ Les autres MCP ne sont pas touchés : leurs processus, leurs sessions et leurs appels en cours continuent.
129
+
130
+ La réponse donne le résultat MCP par MCP :
131
+
132
+ ```json
133
+ {
134
+ "revision": "5ecfab70.3",
135
+ "results": [
136
+ { "name": "documentor", "action": "unchanged", "ok": true },
137
+ { "name": "github", "action": "restarted", "ok": true },
138
+ { "name": "scraper", "action": "started", "ok": false, "error": "MCP error -32000: Connection closed" }
139
+ ]
140
+ }
141
+ ```
142
+
143
+ `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é.
144
+
145
+ Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'authentification OAuth du gateway.
146
+
147
+ ### Révision
148
+
149
+ 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.
150
+
151
+ Sous [périmètre d'accès](#périmètre-daccès), la révision est suffixée de l'empreinte du périmètre (`5ecfab70.3~03d09cc8`) : un changement de droits change la révision, et le relais relit les outils de l'agent comme après un rechargement.
152
+
153
+ ## Périmètre d'accès
154
+
155
+ Le gateway ne sait pas qui l'appelle. Quand il est placé derrière un frontal qui authentifie ses utilisateurs (le hub `mcp-gateway-manager`, par exemple), c'est ce frontal qui décide de ce que chaque requête peut atteindre, et le gateway qui l'applique.
156
+
157
+ Avec `GATEWAY_SCOPE_SECRET` défini, **toute requête** doit porter l'en-tête `X-Gateway-Scope`, signé avec ce secret. Une requête sans en-tête, ou dont la signature est fausse, est refusée en `403`. Seules `POST /admin/reload` (qui a son propre jeton) et `/.well-known/*` y échappent.
158
+
159
+ L'en-tête vaut `<charge>.<signature>` :
160
+
161
+ - `charge` : un objet JSON, encodé en base64url ;
162
+ - `signature` : le HMAC-SHA256 de la charge encodée, avec `GATEWAY_SCOPE_SECRET` pour clé, en base64url.
163
+
164
+ ```jsonc
165
+ { "v": 1, "all": true } // tout le gateway
166
+ { "v": 1, "mcps": {
167
+ "documentor": "*", // tous les outils de ce MCP, y compris ceux ajoutés plus tard
168
+ "github": ["get_info", "pull_request"] // ces outils seulement
169
+ } }
170
+ ```
171
+
172
+ Les MCP sont désignés par leur nom (la clé de `mcpServers`). Le périmètre s'applique partout :
173
+
174
+ | Point d'accès | MCP hors périmètre | Outil hors périmètre |
175
+ |---|---|---|
176
+ | `GET /mcps`, `GET /mcp-definitions`, `list_mcps` | absent | absent de `tools` |
177
+ | `GET /{route}/tools`, `list_tools` | `404` / « No MCP found » | absent |
178
+ | `POST /{route}/tools/:toolName`, `call_tool` | `404` / « No MCP found » | `403` / « Tool not allowed » |
179
+ | `/{route}/http/*` | `404` | — (le relais suit le MCP) |
180
+ | `GET /startup-exposition`, `tools/list` de `/mcp` | outils absents | outil absent |
181
+ | `GET /tools/search` | résultats absents | résultat absent |
182
+
183
+ Le module `scope.js` exporte `encodeScope(charge, secret)`, qui fabrique l'en-tête.
184
+
108
185
  ## Routes
109
186
 
110
187
  ### `GET /mcps`
@@ -651,13 +728,15 @@ points d'entrée par lesquels un agent atteint le gateway :
651
728
  - **bridge stdio** (`server.js --mcp`, cas local) — au démarrage, le bridge interroge le
652
729
  serveur (`GET /startup-exposition`), publie les outils reçus et route leurs appels vers
653
730
  la bonne route. Le bridge ne connaît pas la config : il ne fait que transmettre ce que le
654
- serveur décide.
731
+ serveur décide. Quand une réponse du gateway porte une nouvelle
732
+ [révision](#révision), le bridge relit `GET /startup-exposition` et envoie
733
+ `notifications/tools/list_changed` à l'agent.
655
734
 
656
735
  ---
657
736
 
658
737
  ## Ajouter un MCP
659
738
 
660
739
  1. Ajouter l'entrée dans `config/mcp-servers.json` avec `MCP_SERVER_ROUTE` dans `env`
661
- 2. Redémarrer le gateway
740
+ 2. Appeler `POST /admin/reload` (voir [Rechargement à chaud](#rechargement-à-chaud)), ou redémarrer le gateway
662
741
 
663
742
  Aucune modification du MCP n'est nécessaire.
package/mcp-endpoint.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from '@modelcontextprotocol/sdk/types.js';
7
7
  import { getProtectedResourceMetadata } from './auth.js';
8
8
  import { toolDefinition } from './startup-exposition.js';
9
+ import { allowsMcp, allowsTool, filterTools } from './scope.js';
9
10
 
10
11
  const tools = [
11
12
  {
@@ -39,16 +40,27 @@ const tools = [
39
40
  },
40
41
  ];
41
42
 
42
- function buildMcpServer({ registry, proxyTools = [] }) {
43
+ /**
44
+ * Serveur MCP d'une requête, restreint à son périmètre (voir scope.js) : un MCP hors
45
+ * périmètre est introuvable, un outil hors périmètre n'est ni listé ni appelable.
46
+ */
47
+ function buildMcpServer(registry, scope) {
43
48
  const server = new Server(
44
49
  { name: 'mcp-http-gateway-remote', version: '1.0.0' },
45
50
  { capabilities: { tools: {} } }
46
51
  );
47
52
 
48
53
  // Outils des MCP pré-exposés en ALL : publiés tels quels, appelables sans call_tool.
54
+ const proxyTools = registry.proxyTools.filter(t => allowsTool(scope, t.mcpName, t.toolName));
49
55
  const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
50
56
  const allTools = [...tools, ...proxyTools.map(toolDefinition)];
51
57
 
58
+ /** Le MCP servi sur cette route, s'il est dans le périmètre. */
59
+ const visibleEntry = (route) => {
60
+ const entry = registry.byRoute(route);
61
+ return entry && allowsMcp(scope, entry.name) ? entry : null;
62
+ };
63
+
52
64
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
53
65
 
54
66
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -57,7 +69,7 @@ function buildMcpServer({ registry, proxyTools = [] }) {
57
69
  try {
58
70
  const proxy = proxyIndex.get(name);
59
71
  if (proxy) {
60
- const entry = registry.find(m => m.route === proxy.route);
72
+ const entry = visibleEntry(proxy.route);
61
73
  if (!entry) {
62
74
  return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
63
75
  }
@@ -65,26 +77,30 @@ function buildMcpServer({ registry, proxyTools = [] }) {
65
77
  }
66
78
 
67
79
  if (name === 'list_mcps') {
68
- const text = registry
80
+ const text = registry.entries
81
+ .filter(m => allowsMcp(scope, m.name))
69
82
  .map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
70
83
  .join('\n');
71
84
  return { content: [{ type: 'text', text }] };
72
85
  }
73
86
 
74
87
  if (name === 'list_tools') {
75
- const entry = registry.find(m => m.route === args.route);
88
+ const entry = visibleEntry(args.route);
76
89
  if (!entry) {
77
90
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
78
91
  }
79
- const text = JSON.stringify(entry.tools);
92
+ const text = JSON.stringify(filterTools(scope, entry.name, entry.tools));
80
93
  return { content: [{ type: 'text', text }] };
81
94
  }
82
95
 
83
96
  if (name === 'call_tool') {
84
- const entry = registry.find(m => m.route === args.route);
97
+ const entry = visibleEntry(args.route);
85
98
  if (!entry) {
86
99
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
87
100
  }
101
+ if (!allowsTool(scope, entry.name, args.tool_name)) {
102
+ return { content: [{ type: 'text', text: `Tool not allowed: ${args.tool_name}` }], isError: true };
103
+ }
88
104
  const result = await entry.client.callTool({
89
105
  name: args.tool_name,
90
106
  arguments: args.arguments ?? {},
@@ -123,7 +139,7 @@ function buildDnsRebindingConfig() {
123
139
  return { allowedHosts, allowedOrigins };
124
140
  }
125
141
 
126
- export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
142
+ export function registerMcpEndpoint(app, registry) {
127
143
  app.get('/.well-known/oauth-protected-resource', (req, res) => {
128
144
  res.json(getProtectedResourceMetadata());
129
145
  });
@@ -138,7 +154,7 @@ export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
138
154
  // Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
139
155
  // et l'endpoint supporte le scale-out multi-instances.
140
156
  app.post('/mcp', async (req, res) => {
141
- const server = buildMcpServer({ registry, proxyTools });
157
+ const server = buildMcpServer(registry, req.scope);
142
158
  const transport = new StreamableHTTPServerTransport({
143
159
  sessionIdGenerator: undefined,
144
160
  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.19",
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,9 @@
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",
22
+ "scope.js"
21
23
  ],
22
24
  "scripts": {
23
25
  "start": "node server.js",
package/scope.js ADDED
@@ -0,0 +1,123 @@
1
+ import crypto from 'crypto';
2
+
3
+ // --- Périmètre d'accès d'une requête ---
4
+ //
5
+ // Le périmètre dit quels MCP, et quels outils de chaque MCP, une requête peut atteindre.
6
+ // Il n'est pas calculé ici : c'est le frontal qui a authentifié l'appelant (le hub
7
+ // mcp-gateway-manager) qui le fixe, puis le transmet dans l'en-tête `X-Gateway-Scope`,
8
+ // signé avec le secret partagé `GATEWAY_SCOPE_SECRET`.
9
+ //
10
+ // Sans `GATEWAY_SCOPE_SECRET`, le gateway ne connaît pas de périmètre : toute requête voit
11
+ // tous les MCP et tous les outils. Avec, toute requête doit porter un périmètre valide,
12
+ // sinon elle est refusée en 403.
13
+ //
14
+ // Format de l'en-tête : `<charge>.<signature>`
15
+ // - charge : JSON encodé en base64url ;
16
+ // - signature : HMAC-SHA256 de la charge (telle qu'encodée), en base64url.
17
+ //
18
+ // Charge, version 1 :
19
+ // { "v": 1, "all": true } → tout le gateway
20
+ // { "v": 1, "mcps": { "documentor": "*", → tous les outils du MCP
21
+ // "github": ["get_info", "clone"] } } → ces outils seulement
22
+ // Un MCP absent de `mcps` est invisible : il n'apparaît dans aucune liste et ses routes
23
+ // répondent comme une route inconnue.
24
+
25
+ export const SCOPE_HEADER = 'X-Gateway-Scope';
26
+
27
+ const SCOPE_VERSION = 1;
28
+
29
+ /** Périmètre d'une requête qui voit tout le gateway. */
30
+ export const FULL_SCOPE = Object.freeze({ all: true, mcps: null, digest: null });
31
+
32
+ function sign(encoded, secret) {
33
+ return crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
34
+ }
35
+
36
+ /** Construit la valeur de l'en-tête pour une charge donnée. */
37
+ export function encodeScope(payload, secret) {
38
+ const encoded = Buffer.from(JSON.stringify({ v: SCOPE_VERSION, ...payload }), 'utf8').toString('base64url');
39
+ return `${encoded}.${sign(encoded, secret)}`;
40
+ }
41
+
42
+ function signatureMatches(encoded, presented, secret) {
43
+ const expected = Buffer.from(sign(encoded, secret));
44
+ const actual = Buffer.from(presented);
45
+ return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
46
+ }
47
+
48
+ function parseMcps(raw) {
49
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
50
+ const mcps = new Map();
51
+ for (const [name, tools] of Object.entries(raw)) {
52
+ if (tools === '*') {
53
+ mcps.set(name, '*');
54
+ } else if (Array.isArray(tools) && tools.every((tool) => typeof tool === 'string')) {
55
+ mcps.set(name, new Set(tools));
56
+ } else {
57
+ return null;
58
+ }
59
+ }
60
+ return mcps;
61
+ }
62
+
63
+ /**
64
+ * Lit et vérifie l'en-tête de périmètre.
65
+ * Rend `{ ok: true, scope }` ou `{ ok: false, error }` — jamais d'exception.
66
+ */
67
+ export function readScope(header, secret) {
68
+ if (typeof header !== 'string' || header === '') {
69
+ return { ok: false, error: `Missing ${SCOPE_HEADER} header` };
70
+ }
71
+ const separator = header.indexOf('.');
72
+ if (separator <= 0 || separator === header.length - 1) {
73
+ return { ok: false, error: `Malformed ${SCOPE_HEADER} header` };
74
+ }
75
+ const encoded = header.slice(0, separator);
76
+ if (!signatureMatches(encoded, header.slice(separator + 1), secret)) {
77
+ return { ok: false, error: `Invalid ${SCOPE_HEADER} signature` };
78
+ }
79
+
80
+ let payload;
81
+ try {
82
+ payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
83
+ } catch {
84
+ return { ok: false, error: `Unreadable ${SCOPE_HEADER} payload` };
85
+ }
86
+ if (payload?.v !== SCOPE_VERSION) {
87
+ return { ok: false, error: `Unsupported ${SCOPE_HEADER} version: ${payload?.v}` };
88
+ }
89
+
90
+ const digest = crypto.createHash('sha256').update(encoded).digest('hex').slice(0, 8);
91
+ if (payload.all === true) return { ok: true, scope: { all: true, mcps: null, digest } };
92
+
93
+ const mcps = parseMcps(payload.mcps);
94
+ if (!mcps) return { ok: false, error: `Invalid ${SCOPE_HEADER} payload` };
95
+ return { ok: true, scope: { all: false, mcps, digest } };
96
+ }
97
+
98
+ /** Le MCP est-il visible dans ce périmètre ? */
99
+ export function allowsMcp(scope, mcpName) {
100
+ return scope.all || scope.mcps.has(mcpName);
101
+ }
102
+
103
+ /** L'outil est-il appelable dans ce périmètre ? */
104
+ export function allowsTool(scope, mcpName, toolName) {
105
+ if (scope.all) return true;
106
+ const tools = scope.mcps.get(mcpName);
107
+ if (tools === undefined) return false;
108
+ return tools === '*' || tools.has(toolName);
109
+ }
110
+
111
+ /** Les outils d'un MCP réduits à ceux du périmètre. */
112
+ export function filterTools(scope, mcpName, tools) {
113
+ return (tools ?? []).filter((tool) => allowsTool(scope, mcpName, tool.name));
114
+ }
115
+
116
+ /**
117
+ * Révision servie à une requête : celle du registre, suffixée de l'empreinte du périmètre.
118
+ * Un changement de droits change donc la révision, ce qui suffit au relais stdio pour
119
+ * relire ses outils.
120
+ */
121
+ export function scopedRevision(revision, scope) {
122
+ return scope.digest ? `${revision}~${scope.digest}` : revision;
123
+ }
package/server.js CHANGED
@@ -13,17 +13,19 @@ 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';
26
+ import {
27
+ FULL_SCOPE, SCOPE_HEADER, allowsMcp, allowsTool, filterTools, readScope, scopedRevision,
28
+ } from './scope.js';
27
29
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
28
30
  import { rateLimit } from 'express-rate-limit';
29
31
 
@@ -38,65 +40,67 @@ function normalizeCfg(cfg) {
38
40
  return { ...cfg, command, args, env };
39
41
  }
40
42
 
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
- }
43
+ // --- Routes des MCP ---
44
+
45
+ /**
46
+ * Routes `<route>/tools`, `<route>/tools/:toolName` et `<route>/http/*` de chaque MCP,
47
+ * résolues sur le registre à chaque requête et restreintes au périmètre de la requête :
48
+ * un MCP hors périmètre répond comme une route inconnue, un outil hors périmètre est
49
+ * absent de la liste et refusé en 403.
50
+ */
51
+ function mcpRoutes(registry) {
52
+ return async (req, res, next) => {
53
+ for (const entry of registry.entries) {
54
+ const { route, name, client } = entry;
55
+ if (req.path !== route && !req.path.startsWith(`${route}/`)) continue;
56
+ if (!allowsMcp(req.scope, name)) return next();
57
+ const rest = req.path.slice(route.length);
58
+
59
+ if (entry.relayPort && (rest === '/http' || rest.startsWith('/http/'))) {
60
+ req.url = req.url.slice(relayPrefix(route).length) || '/';
61
+ return createHttpRelay({ name, port: entry.relayPort })(req, res);
62
+ }
60
63
 
61
- // --- MCP client ---
64
+ if (req.method === 'GET' && rest === '/tools') {
65
+ try {
66
+ const { tools } = await client.listTools();
67
+ return res.json(filterTools(req.scope, name, tools));
68
+ } catch (err) {
69
+ console.error(`[${name}] listTools error:`, err.message);
70
+ return res.status(500).json({ error: err.message });
71
+ }
72
+ }
62
73
 
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;
74
+ const toolMatch = /^\/tools\/([^/]+)$/.exec(rest);
75
+ if (req.method === 'POST' && toolMatch) {
76
+ const toolName = decodeURIComponent(toolMatch[1]);
77
+ if (!allowsTool(req.scope, name, toolName)) {
78
+ return res.status(403).json({ error: `Tool not allowed: ${toolName}` });
79
+ }
80
+ try {
81
+ const result = await client.callTool({ name: toolName, arguments: req.body ?? {} });
82
+ return res.json(result);
83
+ } catch (err) {
84
+ console.error(`[${name}] callTool(${toolName}) error:`, err.message);
85
+ return res.status(500).json({ error: err.message });
86
+ }
87
+ }
88
+ }
89
+ return next();
90
+ };
73
91
  }
74
92
 
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
- });
93
+ function readServers(configPath) {
94
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
95
+ return config.mcpServers ?? {};
96
+ }
87
97
 
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
- });
98
+ /** Comparaison à temps constant du jeton d'administration présenté. */
99
+ function adminTokenMatches(presented, expected) {
100
+ if (typeof presented !== 'string') return false;
101
+ const a = crypto.createHash('sha256').update(presented).digest();
102
+ const b = crypto.createHash('sha256').update(expected).digest();
103
+ return crypto.timingSafeEqual(a, b);
100
104
  }
101
105
 
102
106
  // --- Bootstrap ---
@@ -113,8 +117,18 @@ async function main() {
113
117
  ? path.resolve(process.env.GATEWAY_CONFIG_DIR)
114
118
  : path.join(process.cwd(), 'config');
115
119
  const configPath = path.join(configDir, 'mcp-servers.json');
116
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
117
- const servers = config.mcpServers ?? {};
120
+ const servers = readServers(configPath);
121
+
122
+ const registry = new McpRegistry({
123
+ onToolsLoaded: (name, tools) => {
124
+ for (const tool of tools) {
125
+ if (!tool.description) continue;
126
+ getOrComputeToolEmbedding(name, tool, getEmbedding).catch(err =>
127
+ console.error(`[gateway] Embedding error for ${name}::${tool.name}:`, err.message)
128
+ );
129
+ }
130
+ },
131
+ });
118
132
 
119
133
  const app = express();
120
134
  if (process.env.TRUST_PROXY) {
@@ -127,10 +141,61 @@ async function main() {
127
141
  // qui repartirait sinon en « 413 Payload Too Large » sans explication.
128
142
  const BODY_LIMIT = process.env.GATEWAY_BODY_LIMIT ?? '32mb';
129
143
  const jsonParser = express.json({ type: '*/*', limit: BODY_LIMIT });
144
+ app.use((req, res, next) => {
145
+ res.setHeader(REVISION_HEADER, registry.revision);
146
+ next();
147
+ });
148
+
149
+ // Rechargement de la configuration, réservé au détenteur de GATEWAY_ADMIN_TOKEN.
150
+ // Seuls les MCP dont la configuration a changé sont arrêtés, lancés ou relancés.
151
+ const ADMIN_TOKEN = process.env.GATEWAY_ADMIN_TOKEN?.trim() || null;
152
+ if (ADMIN_TOKEN) {
153
+ app.post('/admin/reload', async (req, res) => {
154
+ if (!adminTokenMatches(req.get('X-Gateway-Admin-Token'), ADMIN_TOKEN)) {
155
+ return res.status(401).json({ error: 'Unauthorized' });
156
+ }
157
+ let nextServers;
158
+ try {
159
+ nextServers = readServers(configPath);
160
+ } catch (err) {
161
+ console.error('[gateway] Reload refused, unreadable configuration:', err.message);
162
+ return res.status(400).json({ error: `Unreadable configuration ${configPath}: ${err.message}` });
163
+ }
164
+ const results = await registry.apply(nextServers);
165
+ const summary = results
166
+ .filter(r => r.action !== 'unchanged')
167
+ .map(r => `${r.name}: ${r.action}${r.ok ? '' : ' (failed)'}`);
168
+ console.log(`[gateway] Reload — ${summary.length ? summary.join(', ') : 'no change'}`);
169
+ return res.json({ revision: registry.revision, results });
170
+ });
171
+ console.log('[gateway] Admin endpoint enabled: POST /admin/reload');
172
+ }
173
+
174
+ // Périmètre de chaque requête (voir scope.js). Sans GATEWAY_SCOPE_SECRET, tout est visible.
175
+ const SCOPE_SECRET = process.env.GATEWAY_SCOPE_SECRET?.trim() || null;
176
+ if (SCOPE_SECRET) {
177
+ app.use((req, res, next) => {
178
+ if (req.path.startsWith('/.well-known/')) {
179
+ req.scope = FULL_SCOPE;
180
+ return next();
181
+ }
182
+ const result = readScope(req.get(SCOPE_HEADER), SCOPE_SECRET);
183
+ if (!result.ok) return res.status(403).json({ error: result.error });
184
+ req.scope = result.scope;
185
+ res.setHeader(REVISION_HEADER, scopedRevision(registry.revision, result.scope));
186
+ return next();
187
+ });
188
+ console.log(`[gateway] Access scopes enforced: every request must carry a signed ${SCOPE_HEADER} header`);
189
+ } else {
190
+ app.use((req, res, next) => {
191
+ req.scope = FULL_SCOPE;
192
+ next();
193
+ });
194
+ }
195
+
130
196
  // Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
131
- const relayPrefixes = new Set();
132
197
  app.use((req, res, next) =>
133
- isRelayPath(relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
198
+ isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
134
199
  );
135
200
 
136
201
  const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
@@ -196,53 +261,8 @@ async function main() {
196
261
  const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
197
262
  initDb(dbPath);
198
263
 
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
- }
264
+ await registry.apply(servers);
265
+ app.use(mcpRoutes(registry));
246
266
 
247
267
  app.get('/tools/search', searchLimiter, async (req, res) => {
248
268
  const q = req.query.q?.trim();
@@ -251,12 +271,14 @@ async function main() {
251
271
  try {
252
272
  const queryEmbedding = await getEmbedding(q);
253
273
  const results = getAllToolEmbeddings()
274
+ .filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
275
+ .filter(row => allowsTool(req.scope, row.mcp_name, row.tool_name))
254
276
  .map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
255
277
  .sort((a, b) => b.score - a.score)
256
278
  .slice(0, limit)
257
279
  .map(({ mcp_name, tool_name, description, score }) => ({
258
280
  mcp_name,
259
- mcp_description: registry.find(r => r.name === mcp_name)?.description ?? null,
281
+ mcp_description: registry.byName(mcp_name)?.description ?? null,
260
282
  tool_name,
261
283
  description,
262
284
  score,
@@ -270,12 +292,14 @@ async function main() {
270
292
 
271
293
  app.get('/mcp-definitions', (req, res) => {
272
294
  res.json(
273
- registry.map(({ name, route, description, tools }) => ({
274
- id: name,
275
- route,
276
- description,
277
- tools,
278
- }))
295
+ registry.entries
296
+ .filter(({ name }) => allowsMcp(req.scope, name))
297
+ .map(({ name, route, description, tools }) => ({
298
+ id: name,
299
+ route,
300
+ description,
301
+ tools: filterTools(req.scope, name, tools),
302
+ }))
279
303
  );
280
304
  });
281
305
 
@@ -283,19 +307,11 @@ async function main() {
283
307
  // `tools/list`. Exposés en HTTP pour que le bridge stdio (server.js --mcp /
284
308
  // mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
285
309
  // 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
310
  app.get('/startup-exposition', (req, res) => {
298
- res.json({ proxyTools });
311
+ res.json({
312
+ proxyTools: registry.proxyTools.filter(t => allowsTool(req.scope, t.mcpName, t.toolName)),
313
+ revision: scopedRevision(registry.revision, req.scope),
314
+ });
299
315
  });
300
316
 
301
317
  app.get('/mcps', (req, res) => {
@@ -307,7 +323,7 @@ async function main() {
307
323
  '',
308
324
  '## Serveurs',
309
325
  '',
310
- ...registry.map(({ name, route, description, relayPort }) =>
326
+ ...registry.entries.filter(({ name }) => allowsMcp(req.scope, name)).map(({ name, route, description, relayPort }) =>
311
327
  `### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
312
328
  (relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
313
329
  ),
@@ -315,7 +331,7 @@ async function main() {
315
331
  res.type('text/plain').send(lines.join('\n'));
316
332
  });
317
333
 
318
- registerMcpEndpoint(app, { clients, registry, proxyTools });
334
+ registerMcpEndpoint(app, registry);
319
335
 
320
336
  const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
321
337
  console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
@@ -328,7 +344,7 @@ async function main() {
328
344
 
329
345
  const shutdown = async () => {
330
346
  console.log('[gateway] Shutting down...');
331
- await Promise.allSettled(clients.map(c => c.close()));
347
+ await registry.closeAll();
332
348
  process.exit(0);
333
349
  };
334
350
 
@@ -56,8 +56,8 @@ function sanitizeSegment(value) {
56
56
  return String(value).replace(/[^a-zA-Z0-9_-]/g, '_');
57
57
  }
58
58
 
59
- // Construit les outils proxy à partir du registry. Chaque entrée porte `route` +
60
- // `toolName` pour le routage — à retirer avant publication via toolDefinition().
59
+ // Construit les outils proxy à partir du registry. Chaque entrée porte `route`, `mcpName` et
60
+ // `toolName` pour le routage et le périmètre — à retirer avant publication via toolDefinition().
61
61
  export function buildProxyTools(registry) {
62
62
  const proxyTools = [];
63
63
 
@@ -70,6 +70,7 @@ export function buildProxyTools(registry) {
70
70
  description: tool.description ?? '',
71
71
  inputSchema: tool.inputSchema ?? { type: 'object', properties: {} },
72
72
  route: entry.route,
73
+ mcpName: entry.name,
73
74
  toolName: tool.name,
74
75
  });
75
76
  }