@imenam/mcp-http-gateway 2.0.16 → 2.0.20

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
@@ -53,7 +53,12 @@ GATEWAY_CONFIG_DIR=./config
53
53
  | `GATEWAY_PORT` | `3500` | Port HTTP du gateway |
54
54
  | `GATEWAY_CONFIG_DIR` | `./config` (relatif au cwd) | Dossier contenant `mcp-servers.json` |
55
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). |
56
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). |
58
+ | `RATE_LIMIT_GLOBAL_MAX` / `RATE_LIMIT_GLOBAL_WINDOW_MINUTES` | `1000` / `1` | Requêtes acceptées par IP sur la fenêtre, toutes routes confondues ; au-delà, la réponse est un `429`. `0` désactive la limite. |
59
+ | `RATE_LIMIT_SEARCH_MAX` / `RATE_LIMIT_SEARCH_WINDOW_MINUTES` | `30` / `1` | Même principe pour `/tools/search`, en plus de la limite globale. `0` désactive la limite. |
60
+
61
+ Derrière un frontal qui authentifie déjà les appelants et borne leur fréquence, comme mcp-gateway-manager, les deux limites se mettent à `0`.
57
62
 
58
63
  ### `mcp-servers.json`
59
64
 
@@ -147,6 +152,40 @@ Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'auth
147
152
 
148
153
  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
154
 
155
+ 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.
156
+
157
+ ## Périmètre d'accès
158
+
159
+ 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.
160
+
161
+ 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.
162
+
163
+ L'en-tête vaut `<charge>.<signature>` :
164
+
165
+ - `charge` : un objet JSON, encodé en base64url ;
166
+ - `signature` : le HMAC-SHA256 de la charge encodée, avec `GATEWAY_SCOPE_SECRET` pour clé, en base64url.
167
+
168
+ ```jsonc
169
+ { "v": 1, "all": true } // tout le gateway
170
+ { "v": 1, "mcps": {
171
+ "documentor": "*", // tous les outils de ce MCP, y compris ceux ajoutés plus tard
172
+ "github": ["get_info", "pull_request"] // ces outils seulement
173
+ } }
174
+ ```
175
+
176
+ Les MCP sont désignés par leur nom (la clé de `mcpServers`). Le périmètre s'applique partout :
177
+
178
+ | Point d'accès | MCP hors périmètre | Outil hors périmètre |
179
+ |---|---|---|
180
+ | `GET /mcps`, `GET /mcp-definitions`, `list_mcps` | absent | absent de `tools` |
181
+ | `GET /{route}/tools`, `list_tools` | `404` / « No MCP found » | absent |
182
+ | `POST /{route}/tools/:toolName`, `call_tool` | `404` / « No MCP found » | `403` / « Tool not allowed » |
183
+ | `/{route}/http/*` | `404` | — (le relais suit le MCP) |
184
+ | `GET /startup-exposition`, `tools/list` de `/mcp` | outils absents | outil absent |
185
+ | `GET /tools/search` | résultats absents | résultat absent |
186
+
187
+ Le module `scope.js` exporte `encodeScope(charge, secret)`, qui fabrique l'en-tête.
188
+
150
189
  ## Routes
151
190
 
152
191
  ### `GET /mcps`
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,17 +40,27 @@ const tools = [
39
40
  },
40
41
  ];
41
42
 
42
- function buildMcpServer(registry) {
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.
49
- const { proxyTools } = registry;
54
+ const proxyTools = registry.proxyTools.filter(t => allowsTool(scope, t.mcpName, t.toolName));
50
55
  const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
51
56
  const allTools = [...tools, ...proxyTools.map(toolDefinition)];
52
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
+
53
64
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
54
65
 
55
66
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -58,7 +69,7 @@ function buildMcpServer(registry) {
58
69
  try {
59
70
  const proxy = proxyIndex.get(name);
60
71
  if (proxy) {
61
- const entry = registry.byRoute(proxy.route);
72
+ const entry = visibleEntry(proxy.route);
62
73
  if (!entry) {
63
74
  return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
64
75
  }
@@ -67,25 +78,29 @@ function buildMcpServer(registry) {
67
78
 
68
79
  if (name === 'list_mcps') {
69
80
  const text = registry.entries
81
+ .filter(m => allowsMcp(scope, m.name))
70
82
  .map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
71
83
  .join('\n');
72
84
  return { content: [{ type: 'text', text }] };
73
85
  }
74
86
 
75
87
  if (name === 'list_tools') {
76
- const entry = registry.byRoute(args.route);
88
+ const entry = visibleEntry(args.route);
77
89
  if (!entry) {
78
90
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
79
91
  }
80
- const text = JSON.stringify(entry.tools);
92
+ const text = JSON.stringify(filterTools(scope, entry.name, entry.tools));
81
93
  return { content: [{ type: 'text', text }] };
82
94
  }
83
95
 
84
96
  if (name === 'call_tool') {
85
- const entry = registry.byRoute(args.route);
97
+ const entry = visibleEntry(args.route);
86
98
  if (!entry) {
87
99
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
88
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
+ }
89
104
  const result = await entry.client.callTool({
90
105
  name: args.tool_name,
91
106
  arguments: args.arguments ?? {},
@@ -139,7 +154,7 @@ export function registerMcpEndpoint(app, registry) {
139
154
  // Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
140
155
  // et l'endpoint supporte le scale-out multi-instances.
141
156
  app.post('/mcp', async (req, res) => {
142
- const server = buildMcpServer(registry);
157
+ const server = buildMcpServer(registry, req.scope);
143
158
  const transport = new StreamableHTTPServerTransport({
144
159
  sessionIdGenerator: undefined,
145
160
  enableJsonResponse: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-http-gateway",
3
- "version": "2.0.16",
3
+ "version": "2.0.20",
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",
@@ -18,7 +18,8 @@
18
18
  "http-relay.js",
19
19
  "gateway-exec.js",
20
20
  "gateway-watch.js",
21
- "mcp-registry.js"
21
+ "mcp-registry.js",
22
+ "scope.js"
22
23
  ],
23
24
  "scripts": {
24
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
@@ -23,6 +23,9 @@ import { registerMcpEndpoint } from './mcp-endpoint.js';
23
23
  import { createMcpTokenVerifier } from './auth.js';
24
24
  import { relayPrefix, isRelayPath, createHttpRelay } from './http-relay.js';
25
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';
26
29
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
27
30
  import { rateLimit } from 'express-rate-limit';
28
31
 
@@ -41,13 +44,16 @@ function normalizeCfg(cfg) {
41
44
 
42
45
  /**
43
46
  * Routes `<route>/tools`, `<route>/tools/:toolName` et `<route>/http/*` de chaque MCP,
44
- * résolues sur le registre à chaque requête.
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.
45
50
  */
46
51
  function mcpRoutes(registry) {
47
52
  return async (req, res, next) => {
48
53
  for (const entry of registry.entries) {
49
54
  const { route, name, client } = entry;
50
55
  if (req.path !== route && !req.path.startsWith(`${route}/`)) continue;
56
+ if (!allowsMcp(req.scope, name)) return next();
51
57
  const rest = req.path.slice(route.length);
52
58
 
53
59
  if (entry.relayPort && (rest === '/http' || rest.startsWith('/http/'))) {
@@ -58,7 +64,7 @@ function mcpRoutes(registry) {
58
64
  if (req.method === 'GET' && rest === '/tools') {
59
65
  try {
60
66
  const { tools } = await client.listTools();
61
- return res.json(tools);
67
+ return res.json(filterTools(req.scope, name, tools));
62
68
  } catch (err) {
63
69
  console.error(`[${name}] listTools error:`, err.message);
64
70
  return res.status(500).json({ error: err.message });
@@ -68,6 +74,9 @@ function mcpRoutes(registry) {
68
74
  const toolMatch = /^\/tools\/([^/]+)$/.exec(rest);
69
75
  if (req.method === 'POST' && toolMatch) {
70
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
+ }
71
80
  try {
72
81
  const result = await client.callTool({ name: toolName, arguments: req.body ?? {} });
73
82
  return res.json(result);
@@ -162,33 +171,60 @@ async function main() {
162
171
  console.log('[gateway] Admin endpoint enabled: POST /admin/reload');
163
172
  }
164
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
+
165
196
  // Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
166
197
  app.use((req, res, next) =>
167
198
  isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
168
199
  );
169
200
 
170
- const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
171
- const globalMax = parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '300', 10);
201
+ // Une limite à 0 désactive le compteur correspondant.
202
+ const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '1', 10);
203
+ const globalMax = parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '1000', 10);
172
204
  const searchWindowMin = parseInt(process.env.RATE_LIMIT_SEARCH_WINDOW_MINUTES ?? '1', 10);
173
205
  const searchMax = parseInt(process.env.RATE_LIMIT_SEARCH_MAX ?? '30', 10);
174
206
 
175
- app.use(rateLimit({
176
- windowMs: globalWindowMin * 60_000,
177
- max: globalMax,
178
- standardHeaders: 'draft-8',
179
- legacyHeaders: false,
180
- message: { error: 'Too Many Requests' },
181
- }));
182
- console.log(`[gateway] Rate limit — global: ${globalMax} req/${globalWindowMin} min/IP`);
183
-
184
- const searchLimiter = rateLimit({
185
- windowMs: searchWindowMin * 60_000,
186
- max: searchMax,
207
+ const limiter = (windowMin, max) => rateLimit({
208
+ windowMs: windowMin * 60_000,
209
+ limit: max,
187
210
  standardHeaders: 'draft-8',
188
211
  legacyHeaders: false,
189
212
  message: { error: 'Too Many Requests' },
190
213
  });
191
- console.log(`[gateway] Rate limit — /tools/search: ${searchMax} req/${searchWindowMin} min/IP`);
214
+
215
+ if (globalMax > 0) {
216
+ app.use(limiter(globalWindowMin, globalMax));
217
+ console.log(`[gateway] Rate limit — global: ${globalMax} req/${globalWindowMin} min/IP`);
218
+ } else {
219
+ console.log('[gateway] Rate limit — global: disabled');
220
+ }
221
+
222
+ const searchLimiter = searchMax > 0
223
+ ? limiter(searchWindowMin, searchMax)
224
+ : (req, res, next) => next();
225
+ console.log(searchMax > 0
226
+ ? `[gateway] Rate limit — /tools/search: ${searchMax} req/${searchWindowMin} min/IP`
227
+ : '[gateway] Rate limit — /tools/search: disabled');
192
228
 
193
229
  const verifier = createMcpTokenVerifier();
194
230
  if (verifier) {
@@ -241,6 +277,7 @@ async function main() {
241
277
  const queryEmbedding = await getEmbedding(q);
242
278
  const results = getAllToolEmbeddings()
243
279
  .filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
280
+ .filter(row => allowsTool(req.scope, row.mcp_name, row.tool_name))
244
281
  .map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
245
282
  .sort((a, b) => b.score - a.score)
246
283
  .slice(0, limit)
@@ -260,12 +297,14 @@ async function main() {
260
297
 
261
298
  app.get('/mcp-definitions', (req, res) => {
262
299
  res.json(
263
- registry.entries.map(({ name, route, description, tools }) => ({
264
- id: name,
265
- route,
266
- description,
267
- tools,
268
- }))
300
+ registry.entries
301
+ .filter(({ name }) => allowsMcp(req.scope, name))
302
+ .map(({ name, route, description, tools }) => ({
303
+ id: name,
304
+ route,
305
+ description,
306
+ tools: filterTools(req.scope, name, tools),
307
+ }))
269
308
  );
270
309
  });
271
310
 
@@ -274,7 +313,10 @@ async function main() {
274
313
  // mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
275
314
  // la config vit uniquement côté serveur, le bridge se contente de transmettre.
276
315
  app.get('/startup-exposition', (req, res) => {
277
- res.json({ proxyTools: registry.proxyTools, revision: registry.revision });
316
+ res.json({
317
+ proxyTools: registry.proxyTools.filter(t => allowsTool(req.scope, t.mcpName, t.toolName)),
318
+ revision: scopedRevision(registry.revision, req.scope),
319
+ });
278
320
  });
279
321
 
280
322
  app.get('/mcps', (req, res) => {
@@ -286,7 +328,7 @@ async function main() {
286
328
  '',
287
329
  '## Serveurs',
288
330
  '',
289
- ...registry.entries.map(({ name, route, description, relayPort }) =>
331
+ ...registry.entries.filter(({ name }) => allowsMcp(req.scope, name)).map(({ name, route, description, relayPort }) =>
290
332
  `### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
291
333
  (relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
292
334
  ),
@@ -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
  }