@imenam/mcp-http-gateway 2.0.14 → 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/server.js CHANGED
@@ -1,21 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import 'dotenv/config';
3
3
 
4
- if (process.argv.includes('--mcp')) {
4
+ if (process.argv.includes('--watch')) {
5
+ // Veilleur lancé par l'agent via Monitor (voir gateway-watch.js) : il n'ouvre ni serveur ni relais.
6
+ const { runWatchMode } = await import('./gateway-watch.js');
7
+ await runWatchMode(process.argv[process.argv.indexOf('--watch') + 1]).catch((err) => {
8
+ console.error(`[gateway-watch] ${err.message}`);
9
+ process.exit(1);
10
+ });
11
+ } else if (process.argv.includes('--mcp')) {
5
12
  await import('./mcp-mode.js');
6
13
  // Process stays alive: the MCP transport keeps stdin open until the client disconnects
7
14
  }
8
15
 
16
+ import crypto from 'crypto';
9
17
  import { readFileSync } from 'fs';
10
18
  import path from 'path';
11
19
  import express from 'express';
12
- import { Client } from '@modelcontextprotocol/sdk/client/index.js';
13
- import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
14
20
  import { initDb, getOrComputeToolEmbedding, getAllToolEmbeddings } from './embeddings/db.js';
15
21
  import { getEmbedding, cosineSimilarity } from './embeddings/vectorizer.js';
16
22
  import { registerMcpEndpoint } from './mcp-endpoint.js';
17
- import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
18
23
  import { createMcpTokenVerifier } from './auth.js';
24
+ import { relayPrefix, isRelayPath, createHttpRelay } from './http-relay.js';
25
+ import { McpRegistry, REVISION_HEADER } from './mcp-registry.js';
19
26
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
20
27
  import { rateLimit } from 'express-rate-limit';
21
28
 
@@ -30,65 +37,61 @@ function normalizeCfg(cfg) {
30
37
  return { ...cfg, command, args, env };
31
38
  }
32
39
 
33
- // Variables système minimales transmises aux MCPs enfants.
34
- // On exclut délibérément toutes les variables propres au gateway
35
- // (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
36
- const CHILD_ENV_WHITELIST = new Set([
37
- 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
38
- 'LANG', 'LC_ALL', 'LC_CTYPE',
39
- 'TEMP', 'TMP', 'TMPDIR',
40
- 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
41
- 'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
42
- 'COMSPEC', 'PATHEXT', 'USERNAME',
43
- 'PROGRAMFILES', 'NODE_PATH',
44
- ]);
45
-
46
- function buildChildEnv(cfgEnv = {}) {
47
- const safeEnv = Object.fromEntries(
48
- Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
49
- );
50
- return { ...safeEnv, ...cfgEnv };
51
- }
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
+ }
52
57
 
53
- // --- 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
+ }
54
67
 
55
- async function connectMcp(name, cfg) {
56
- const transport = new StdioClientTransport({
57
- command: cfg.command,
58
- args: cfg.args ?? [],
59
- env: buildChildEnv(cfg.env),
60
- });
61
- const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
62
- await client.connect(transport);
63
- console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
64
- 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
+ };
65
82
  }
66
83
 
67
- // --- Routes ---
68
-
69
- function registerMcpRoutes(app, route, client, name) {
70
- app.get(`${route}/tools`, async (req, res) => {
71
- try {
72
- const { tools } = await client.listTools();
73
- res.json(tools);
74
- } catch (err) {
75
- console.error(`[${name}] listTools error:`, err.message);
76
- res.status(500).json({ error: err.message });
77
- }
78
- });
84
+ function readServers(configPath) {
85
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
86
+ return config.mcpServers ?? {};
87
+ }
79
88
 
80
- app.post(`${route}/tools/:toolName`, async (req, res) => {
81
- try {
82
- const result = await client.callTool({
83
- name: req.params.toolName,
84
- arguments: req.body ?? {},
85
- });
86
- res.json(result);
87
- } catch (err) {
88
- console.error(`[${name}] callTool(${req.params.toolName}) error:`, err.message);
89
- res.status(500).json({ error: err.message });
90
- }
91
- });
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);
92
95
  }
93
96
 
94
97
  // --- Bootstrap ---
@@ -105,8 +108,18 @@ async function main() {
105
108
  ? path.resolve(process.env.GATEWAY_CONFIG_DIR)
106
109
  : path.join(process.cwd(), 'config');
107
110
  const configPath = path.join(configDir, 'mcp-servers.json');
108
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
109
- 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
+ });
110
123
 
111
124
  const app = express();
112
125
  if (process.env.TRUST_PROXY) {
@@ -118,7 +131,41 @@ async function main() {
118
131
  // contenu d'un fichier encodé en base64 (cf. l'inlining du relais dans mcp-mode.js),
119
132
  // qui repartirait sinon en « 413 Payload Too Large » sans explication.
120
133
  const BODY_LIMIT = process.env.GATEWAY_BODY_LIMIT ?? '32mb';
121
- app.use(express.json({ type: '*/*', limit: BODY_LIMIT }));
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
+
165
+ // Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
166
+ app.use((req, res, next) =>
167
+ isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
168
+ );
122
169
 
123
170
  const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
124
171
  const globalMax = parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '300', 10);
@@ -183,40 +230,8 @@ async function main() {
183
230
  const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
184
231
  initDb(dbPath);
185
232
 
186
- const clients = [];
187
- const registry = []; // { name, route, description, tools, exposeAtStartup }
188
-
189
- for (const [name, cfg] of Object.entries(servers)) {
190
- const route = cfg.env?.MCP_SERVER_ROUTE;
191
- if (!route) {
192
- console.warn(`[gateway] Skipping "${name}": no MCP_SERVER_ROUTE defined`);
193
- continue;
194
- }
195
-
196
- // Directive gateway déclarée par MCP dans son bloc `env` (comme MCP_SERVER_ROUTE) :
197
- // pré-expose ou non ses outils à l'agent. Absente → MCP non pré-exposé.
198
- const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
199
- const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
200
- if (!valid) {
201
- console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
202
- }
203
-
204
- try {
205
- const client = await connectMcp(name, cfg);
206
- const { tools } = await client.listTools();
207
- clients.push(client);
208
- registry.push({ name, route, description: cfg.description ?? null, tools, client, exposeAtStartup });
209
- registerMcpRoutes(app, route, client, name);
210
- for (const tool of tools) {
211
- if (!tool.description) continue;
212
- getOrComputeToolEmbedding(name, tool, getEmbedding).catch(err =>
213
- console.error(`[gateway] Embedding error for ${name}::${tool.name}:`, err.message)
214
- );
215
- }
216
- } catch (err) {
217
- console.error(`[gateway] Failed to connect "${name}":`, err.message);
218
- }
219
- }
233
+ await registry.apply(servers);
234
+ app.use(mcpRoutes(registry));
220
235
 
221
236
  app.get('/tools/search', searchLimiter, async (req, res) => {
222
237
  const q = req.query.q?.trim();
@@ -225,12 +240,13 @@ async function main() {
225
240
  try {
226
241
  const queryEmbedding = await getEmbedding(q);
227
242
  const results = getAllToolEmbeddings()
243
+ .filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
228
244
  .map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
229
245
  .sort((a, b) => b.score - a.score)
230
246
  .slice(0, limit)
231
247
  .map(({ mcp_name, tool_name, description, score }) => ({
232
248
  mcp_name,
233
- mcp_description: registry.find(r => r.name === mcp_name)?.description ?? null,
249
+ mcp_description: registry.byName(mcp_name)?.description ?? null,
234
250
  tool_name,
235
251
  description,
236
252
  score,
@@ -244,7 +260,7 @@ async function main() {
244
260
 
245
261
  app.get('/mcp-definitions', (req, res) => {
246
262
  res.json(
247
- registry.map(({ name, route, description, tools }) => ({
263
+ registry.entries.map(({ name, route, description, tools }) => ({
248
264
  id: name,
249
265
  route,
250
266
  description,
@@ -257,19 +273,8 @@ async function main() {
257
273
  // `tools/list`. Exposés en HTTP pour que le bridge stdio (server.js --mcp /
258
274
  // mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
259
275
  // la config vit uniquement côté serveur, le bridge se contente de transmettre.
260
- const proxyTools = buildProxyTools(registry);
261
-
262
- const preExposed = registry.filter(r => r.exposeAtStartup);
263
- if (preExposed.length) {
264
- console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map(r => r.name).join(', ')} — ${proxyTools.length} tool(s) published directly in tools/list`);
265
- }
266
- const overlong = overlongProxyNames(proxyTools);
267
- if (overlong.length) {
268
- console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
269
- }
270
-
271
276
  app.get('/startup-exposition', (req, res) => {
272
- res.json({ proxyTools });
277
+ res.json({ proxyTools: registry.proxyTools, revision: registry.revision });
273
278
  });
274
279
 
275
280
  app.get('/mcps', (req, res) => {
@@ -281,14 +286,15 @@ async function main() {
281
286
  '',
282
287
  '## Serveurs',
283
288
  '',
284
- ...registry.map(({ name, route, description }) =>
285
- `### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}`
289
+ ...registry.entries.map(({ name, route, description, relayPort }) =>
290
+ `### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
291
+ (relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
286
292
  ),
287
293
  ];
288
294
  res.type('text/plain').send(lines.join('\n'));
289
295
  });
290
296
 
291
- registerMcpEndpoint(app, { clients, registry, proxyTools });
297
+ registerMcpEndpoint(app, registry);
292
298
 
293
299
  const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
294
300
  console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
@@ -301,7 +307,7 @@ async function main() {
301
307
 
302
308
  const shutdown = async () => {
303
309
  console.log('[gateway] Shutting down...');
304
- await Promise.allSettled(clients.map(c => c.close()));
310
+ await registry.closeAll();
305
311
  process.exit(0);
306
312
  };
307
313
 
@@ -309,7 +315,7 @@ async function main() {
309
315
  process.on('SIGINT', shutdown);
310
316
  }
311
317
 
312
- if (!process.argv.includes('--mcp')) {
318
+ if (!process.argv.includes('--mcp') && !process.argv.includes('--watch')) {
313
319
  main().catch(err => {
314
320
  console.error('[gateway] Fatal error:', err.message);
315
321
  process.exit(1);