@imenam/mcp-http-gateway 2.0.16 → 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
@@ -53,6 +53,7 @@ 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). |
57
58
 
58
59
  ### `mcp-servers.json`
@@ -147,6 +148,40 @@ Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'auth
147
148
 
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.
149
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
+
150
185
  ## Routes
151
186
 
152
187
  ### `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.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",
@@ -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,6 +171,28 @@ 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)
@@ -241,6 +272,7 @@ async function main() {
241
272
  const queryEmbedding = await getEmbedding(q);
242
273
  const results = getAllToolEmbeddings()
243
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))
244
276
  .map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
245
277
  .sort((a, b) => b.score - a.score)
246
278
  .slice(0, limit)
@@ -260,12 +292,14 @@ async function main() {
260
292
 
261
293
  app.get('/mcp-definitions', (req, res) => {
262
294
  res.json(
263
- registry.entries.map(({ name, route, description, tools }) => ({
264
- id: name,
265
- route,
266
- description,
267
- tools,
268
- }))
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
+ }))
269
303
  );
270
304
  });
271
305
 
@@ -274,7 +308,10 @@ async function main() {
274
308
  // mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
275
309
  // la config vit uniquement côté serveur, le bridge se contente de transmettre.
276
310
  app.get('/startup-exposition', (req, res) => {
277
- res.json({ proxyTools: registry.proxyTools, revision: registry.revision });
311
+ res.json({
312
+ proxyTools: registry.proxyTools.filter(t => allowsTool(req.scope, t.mcpName, t.toolName)),
313
+ revision: scopedRevision(registry.revision, req.scope),
314
+ });
278
315
  });
279
316
 
280
317
  app.get('/mcps', (req, res) => {
@@ -286,7 +323,7 @@ async function main() {
286
323
  '',
287
324
  '## Serveurs',
288
325
  '',
289
- ...registry.entries.map(({ name, route, description, relayPort }) =>
326
+ ...registry.entries.filter(({ name }) => allowsMcp(req.scope, name)).map(({ name, route, description, relayPort }) =>
290
327
  `### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
291
328
  (relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
292
329
  ),
@@ -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
  }