@imenam/mcp-http-gateway 2.0.7 → 2.0.8

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
@@ -182,6 +182,42 @@ GATEWAY_URL=http://host.docker.internal:3500 npx @imenam/mcp-http-gateway --mcp
182
182
 
183
183
  > `host.docker.internal` est l'adresse de l'hôte depuis un conteneur Docker.
184
184
 
185
+ ### Variables d'environnement du mode `--mcp`
186
+
187
+ | Variable | Requis | Description |
188
+ |---|---|---|
189
+ | `GATEWAY_URL` | oui | Base HTTP du gateway à relayer, sans barre finale. Le relais compose lui-même ses routes REST (`/startup-exposition`, `/mcp-definitions`, `/{route}/tools`, `/{route}/tools/{outil}`) : **ne pas** y ajouter `/mcp`. |
190
+ | `GATEWAY_TOKEN` | non | Token porteur. S'il est défini, **tous** les appels sortants du relais portent `Authorization: Bearer <token>`. Absent, aucun en-tête n'est ajouté — le comportement est exactement celui des versions antérieures. |
191
+
192
+ > Ces deux variables sont lues dans l'environnement du process. Le relais fait
193
+ > `import 'dotenv/config'` : un fichier `.env` présent dans le **répertoire courant de
194
+ > l'agent** peut donc les fournir. dotenv n'écrase jamais une variable déjà définie —
195
+ > le bloc `env` de la configuration MCP l'emporte toujours.
196
+
197
+ ### Derrière un frontal qui authentifie
198
+
199
+ Quand le gateway n'est pas joint en direct mais à travers un frontal qui exige un token
200
+ porteur (hub [`mcp-gateway-manager`](https://github.com/Matthieu-Pesnot-Pin), reverse-proxy
201
+ maison…), `GATEWAY_TOKEN` est ce que le relais présente à chaque requête :
202
+
203
+ ```json
204
+ {
205
+ "mcpServers": {
206
+ "gateway": {
207
+ "command": "npx",
208
+ "args": ["@imenam/mcp-http-gateway", "--mcp"],
209
+ "env": {
210
+ "GATEWAY_URL": "https://my-gateway.fr/jskdlf-jsfklhj",
211
+ "GATEWAY_TOKEN": "…"
212
+ }
213
+ }
214
+ }
215
+ }
216
+ ```
217
+
218
+ Un refus (`401`/`403`) est rapporté à l'agent en clair — token manquant, ou token invalide —
219
+ plutôt qu'en `HTTP 401` nu. Le token lui-même n'est **jamais** journalisé, pas même tronqué.
220
+
185
221
  ### Outils exposés
186
222
 
187
223
  Le mode MCP expose 3 outils qui reflètent directement les routes du gateway :
@@ -198,7 +234,15 @@ L'agent découvre d'abord les MCPs disponibles, explore les outils d'un MCP si b
198
234
 
199
235
  ## Mode MCP distant (HTTPS / Claude.ai)
200
236
 
201
- Le gateway expose également un **endpoint MCP natif** sur `/mcp` via le transport **Streamable HTTP** (sessions `mcp-session-id`). Ce mode permet à Claude.ai — et à tout client MCP distant compatible — de se connecter directement au gateway, sans intermédiaire.
237
+ Le gateway expose également un **endpoint MCP natif** sur `/mcp` via le transport **Streamable HTTP**, en mode **stateless**. Ce mode permet à Claude.ai — et à tout client MCP distant compatible — de se connecter directement au gateway, sans intermédiaire.
238
+
239
+ Chaque `POST /mcp` est traité de façon autonome : aucun `mcp-session-id` n'est émis, aucun état n'est conservé entre deux requêtes. Les trois outils exposés étant du pur requête/réponse (aucune notification serveur→client), aucune session n'est nécessaire. Concrètement :
240
+
241
+ - un connecteur laissé inactif ne peut pas « expirer » côté serveur — il n'y a rien à expirer ;
242
+ - un redémarrage ou un redéploiement du gateway est transparent pour les clients connectés ;
243
+ - l'endpoint peut être scalé horizontalement derrière un load balancer sans affinité de session.
244
+
245
+ `GET /mcp` et `DELETE /mcp` répondent `405 Method Not Allowed` : sans session, il n'y a ni flux SSE autonome ni terminaison de session à gérer (la spec Streamable HTTP l'autorise explicitement).
202
246
 
203
247
  ### Flow
204
248
 
@@ -263,6 +307,75 @@ Les mêmes 3 outils que le mode stdio — avec accès direct aux clients MCP int
263
307
  | `list_tools` | Liste les outils d'un MCP donné (`route` requis) |
264
308
  | `call_tool` | Appelle un outil (`route`, `tool_name`, `arguments`) |
265
309
 
310
+ ### Pré-exposition de MCP au démarrage (`EXPOSE_AT_AGENT_STARTUP`)
311
+
312
+ Par défaut, l'agent ne reçoit que les 3 outils de découverte et doit appeler `list_mcps` /
313
+ `list_tools` pour découvrir les MCP. Pour les MCP fréquemment utilisés, la clé
314
+ `EXPOSE_AT_AGENT_STARTUP` publie leurs outils **directement dans la réponse `tools/list`**,
315
+ nommés `<serveur>__<outil>`, avec description et `inputSchema` complets. L'agent les appelle
316
+ directement, sans passer par `call_tool` et sans aucun aller-retour de découverte.
317
+
318
+ L'option se déclare **par MCP**, dans son bloc `env` de `config/mcp-servers.json` — au même
319
+ endroit que `MCP_SERVER_ROUTE`, qui est également une directive lue par le gateway :
320
+
321
+ ```json
322
+ {
323
+ "mcpServers": {
324
+ "documentor": {
325
+ "command": "node",
326
+ "args": ["/chemin/vers/documentor/dist/index.js"],
327
+ "description": "Documentation manager MCP",
328
+ "env": {
329
+ "MCP_SERVER_ROUTE": "/documentor",
330
+ "EXPOSE_AT_AGENT_STARTUP": "true"
331
+ }
332
+ },
333
+ "scraper": {
334
+ "command": "node",
335
+ "args": ["/chemin/vers/scraper/dist/index.js"],
336
+ "env": {
337
+ "MCP_SERVER_ROUTE": "/scraper"
338
+ }
339
+ }
340
+ }
341
+ }
342
+ ```
343
+
344
+ Ici `documentor` est pré-exposé ; `scraper`, sans la clé, ne l'est pas et reste accessible
345
+ via `list_mcps` / `list_tools` / `call_tool`. Le `.env` du gateway ne contient **aucune**
346
+ indication relative aux MCP.
347
+
348
+ La valeur est un booléen : `"true"` (également `"1"`, `"yes"`, `"on"`) ou `"false"`. Toute
349
+ autre valeur est refusée avec un avertissement au démarrage et le MCP n'est pas pré-exposé.
350
+
351
+ **Pourquoi ce canal ?** Les définitions d'outils passent par `tools/list`, seul canal sans
352
+ limite de taille. Le champ `instructions` du protocole, lui, est plafonné par les clients
353
+ autour de **2 048 caractères** et tronqué silencieusement au-delà — un MCP de 30 outils
354
+ avec ses schémas pèse ~33 000 caractères, il ne peut donc pas y transiter.
355
+
356
+ **Quand l'activer ?** Le compromis, pour un MCP réellement utilisé :
357
+
358
+ | | Découverte (défaut) | Pré-exposé |
359
+ |---|---|---|
360
+ | Allers-retours | 3 (`list_mcps`, `list_tools`, `call_tool`) | 1 (appel direct) |
361
+ | Contexte | `list_tools` déverse tous les schémas dans la conversation dès le 1ᵉʳ usage, définitivement | les noms d'outils, puis le schéma des seuls outils utilisés |
362
+
363
+ À activer donc pour les MCP utilisés dans la majorité des sessions. À laisser en découverte
364
+ pour les MCP occasionnels : un MCP pré-exposé mais inutilisé coûte ses noms d'outils à
365
+ chaque requête, et multiplier les outils similaires dégrade la sélection par l'agent.
366
+
367
+ Dans tous les cas, le MCP enfant reste hébergé côté gateway : seules ses **définitions**
368
+ sont relayées, les appels sont proxifiés. L'isolation est préservée.
369
+
370
+ Cette config vit **uniquement côté serveur** et s'applique de façon transparente aux deux
371
+ points d'entrée par lesquels un agent atteint le gateway :
372
+
373
+ - **endpoint `/mcp` distant** (cas Claude.ai) — les outils proxy sont publiés directement ;
374
+ - **bridge stdio** (`server.js --mcp`, cas local) — au démarrage, le bridge interroge le
375
+ serveur (`GET /startup-exposition`), publie les outils reçus et route leurs appels vers
376
+ la bonne route. Le bridge ne connaît pas la config : il ne fait que transmettre ce que le
377
+ serveur décide.
378
+
266
379
  ---
267
380
 
268
381
  ## Ajouter un MCP
package/mcp-endpoint.js CHANGED
@@ -1,12 +1,11 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
2
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
3
  import {
5
4
  ListToolsRequestSchema,
6
5
  CallToolRequestSchema,
7
- isInitializeRequest,
8
6
  } from '@modelcontextprotocol/sdk/types.js';
9
7
  import { getProtectedResourceMetadata } from './auth.js';
8
+ import { toolDefinition } from './startup-exposition.js';
10
9
 
11
10
  const tools = [
12
11
  {
@@ -40,18 +39,31 @@ const tools = [
40
39
  },
41
40
  ];
42
41
 
43
- function buildMcpServer({ registry }) {
42
+ function buildMcpServer({ registry, proxyTools = [] }) {
44
43
  const server = new Server(
45
44
  { name: 'mcp-http-gateway-remote', version: '1.0.0' },
46
45
  { capabilities: { tools: {} } }
47
46
  );
48
47
 
49
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
48
+ // Outils des MCP pré-exposés en ALL : publiés tels quels, appelables sans call_tool.
49
+ const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
50
+ const allTools = [...tools, ...proxyTools.map(toolDefinition)];
51
+
52
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
50
53
 
51
54
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
52
55
  const { name, arguments: args = {} } = request.params;
53
56
 
54
57
  try {
58
+ const proxy = proxyIndex.get(name);
59
+ if (proxy) {
60
+ const entry = registry.find(m => m.route === proxy.route);
61
+ if (!entry) {
62
+ return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
63
+ }
64
+ return await entry.client.callTool({ name: proxy.toolName, arguments: args });
65
+ }
66
+
55
67
  if (name === 'list_mcps') {
56
68
  const text = registry
57
69
  .map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
@@ -64,7 +76,7 @@ function buildMcpServer({ registry }) {
64
76
  if (!entry) {
65
77
  return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
66
78
  }
67
- const text = JSON.stringify(entry.tools, null, 2);
79
+ const text = JSON.stringify(entry.tools);
68
80
  return { content: [{ type: 'text', text }] };
69
81
  }
70
82
 
@@ -111,7 +123,7 @@ function buildDnsRebindingConfig() {
111
123
  return { allowedHosts, allowedOrigins };
112
124
  }
113
125
 
114
- export function registerMcpEndpoint(app, { clients, registry }) {
126
+ export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
115
127
  app.get('/.well-known/oauth-protected-resource', (req, res) => {
116
128
  res.json(getProtectedResourceMetadata());
117
129
  });
@@ -120,87 +132,28 @@ export function registerMcpEndpoint(app, { clients, registry }) {
120
132
  console.log('[gateway] DNS rebinding protection — allowedHosts:', allowedHosts);
121
133
  console.log('[gateway] DNS rebinding protection — allowedOrigins:', allowedOrigins);
122
134
 
123
- const SESSION_TTL_MS = parseInt(process.env.SESSION_TTL_MINUTES ?? '30', 10) * 60_000;
124
- const SESSION_MAX = parseInt(process.env.SESSION_MAX_COUNT ?? '100', 10);
125
- console.log(`[gateway] Session limits — TTL: ${SESSION_TTL_MS / 60_000} min, max: ${SESSION_MAX}`);
126
-
127
- const transports = {};
135
+ // Mode stateless : un Server + un transport éphémères par requête, aucun état inter-requêtes.
136
+ // Les trois outils exposés sont du pur requête/réponse (aucune notification serveur→client,
137
+ // ni sampling, ni subscription), donc aucune session n'est nécessaire.
138
+ // Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
139
+ // et l'endpoint supporte le scale-out multi-instances.
140
+ app.post('/mcp', async (req, res) => {
141
+ const server = buildMcpServer({ registry, proxyTools });
142
+ const transport = new StreamableHTTPServerTransport({
143
+ sessionIdGenerator: undefined,
144
+ enableJsonResponse: true,
145
+ enableDnsRebindingProtection: true,
146
+ allowedHosts,
147
+ allowedOrigins,
148
+ });
149
+
150
+ res.on('close', () => {
151
+ transport.close().catch(() => {});
152
+ server.close().catch(() => {});
153
+ });
128
154
 
129
- const cleanupExpiredSessions = () => {
130
- const now = Date.now();
131
- for (const [sid, entry] of Object.entries(transports)) {
132
- if (now - entry.lastActivity > SESSION_TTL_MS) {
133
- console.log(`[mcp-endpoint] Session ${sid} expired (TTL), closing.`);
134
- entry.transport.close?.();
135
- delete transports[sid];
136
- }
137
- }
138
- };
139
-
140
- const cleanupTimer = setInterval(cleanupExpiredSessions, 60_000);
141
- cleanupTimer.unref();
142
-
143
- const handleMcpRequest = async (req, res) => {
144
155
  try {
145
- const sessionId = req.headers['mcp-session-id'];
146
- const entry = sessionId ? transports[sessionId] : undefined;
147
-
148
- let transport;
149
-
150
- if (!entry && isInitializeRequest(req.body)) {
151
- const activeCount = Object.keys(transports).length;
152
- if (activeCount >= SESSION_MAX) {
153
- console.warn(`[mcp-endpoint] Session cap reached (${activeCount}/${SESSION_MAX}), rejecting initialize.`);
154
- return res.status(429).json({
155
- jsonrpc: '2.0',
156
- error: { code: -32000, message: 'Too Many Requests: session limit reached' },
157
- id: null,
158
- });
159
- }
160
-
161
- const ownerSub = req.auth?.sub ?? null;
162
- transport = new StreamableHTTPServerTransport({
163
- sessionIdGenerator: () => randomUUID(),
164
- enableDnsRebindingProtection: true,
165
- allowedHosts,
166
- allowedOrigins,
167
- onsessioninitialized: (sid) => {
168
- transports[sid] = { transport, ownerSub, lastActivity: Date.now() };
169
- },
170
- });
171
- transport.onclose = () => {
172
- if (transport.sessionId) delete transports[transport.sessionId];
173
- };
174
- await buildMcpServer({ registry }).connect(transport);
175
- } else if (!entry) {
176
- if (sessionId) {
177
- // Session ID fourni mais inconnu (restart serveur, session expirée) → 404
178
- // Le client MCP doit réinitialiser une nouvelle session (spec MCP §Session Management §4)
179
- return res.status(404).json({
180
- jsonrpc: '2.0',
181
- error: { code: -32000, message: 'Session not found' },
182
- id: null,
183
- });
184
- }
185
- // Pas de session ID sur une requête non-initialize → 400
186
- return res.status(400).json({
187
- jsonrpc: '2.0',
188
- error: { code: -32000, message: 'Bad Request: No valid session ID provided' },
189
- id: null,
190
- });
191
- } else {
192
- if (entry.ownerSub !== null && req.auth?.sub !== entry.ownerSub) {
193
- console.warn(`[mcp-endpoint] Session hijacking attempt: session owned by "${entry.ownerSub}", request by "${req.auth?.sub}"`);
194
- return res.status(403).json({
195
- jsonrpc: '2.0',
196
- error: { code: -32000, message: 'Forbidden: session belongs to another client' },
197
- id: null,
198
- });
199
- }
200
- entry.lastActivity = Date.now();
201
- transport = entry.transport;
202
- }
203
-
156
+ await server.connect(transport);
204
157
  await transport.handleRequest(req, res, req.body);
205
158
  } catch (err) {
206
159
  console.error('[mcp-endpoint] handleMcpRequest error:', err);
@@ -212,11 +165,17 @@ export function registerMcpEndpoint(app, { clients, registry }) {
212
165
  });
213
166
  }
214
167
  }
215
- };
168
+ });
216
169
 
217
- app.post('/mcp', handleMcpRequest);
218
- app.get('/mcp', handleMcpRequest);
219
- app.delete('/mcp', handleMcpRequest);
170
+ // Sans session, il n'y a ni flux SSE autonome (GET) ni terminaison de session (DELETE).
171
+ // La spec Streamable HTTP autorise explicitement 405 sur ces deux méthodes.
172
+ const methodNotAllowed = (req, res) => res.status(405).json({
173
+ jsonrpc: '2.0',
174
+ error: { code: -32000, message: 'Method not allowed' },
175
+ id: null,
176
+ });
177
+ app.get('/mcp', methodNotAllowed);
178
+ app.delete('/mcp', methodNotAllowed);
220
179
 
221
- console.log('[gateway] MCP remote endpoint registered at /mcp');
180
+ console.log('[gateway] MCP remote endpoint registered at /mcp (stateless)');
222
181
  }
package/mcp-mode.js CHANGED
@@ -13,8 +13,91 @@ if (!GATEWAY_URL) {
13
13
  process.exit(1);
14
14
  }
15
15
 
16
+ // Token porteur optionnel. Utile quand le gateway n'est pas joint en direct mais à
17
+ // travers un frontal qui authentifie (hub mcp-gateway-manager, reverse-proxy…).
18
+ //
19
+ // ATTENTION — `import 'dotenv/config'` ci-dessus charge le `.env` du **répertoire
20
+ // courant de l'agent**, pas celui du gateway. dotenv n'écrase jamais une variable
21
+ // déjà présente dans l'environnement : le bloc `env` de la config MCP l'emporte
22
+ // donc toujours. En revanche, si le bloc `env` ne définit PAS GATEWAY_TOKEN, un
23
+ // `.env` traînant dans le projet de l'agent peut en fournir un. C'est le
24
+ // comportement historique (GATEWAY_URL est dans le même cas), on ne le change pas.
25
+ //
26
+ // Le token n'est JAMAIS journalisé, même tronqué.
27
+ const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN?.trim() || undefined;
28
+
29
+ /**
30
+ * En-têtes d'un appel sortant du relais.
31
+ *
32
+ * Sans GATEWAY_TOKEN, rien n'est ajouté et l'appel part exactement comme avant —
33
+ * la rétrocompatibilité avec les déploiements existants est stricte.
34
+ */
35
+ function gatewayHeaders(base) {
36
+ const headers = { ...base };
37
+ if (GATEWAY_TOKEN) headers.Authorization = `Bearer ${GATEWAY_TOKEN}`;
38
+ return Object.keys(headers).length > 0 ? headers : undefined;
39
+ }
40
+
41
+ /** Le seul point de sortie HTTP du relais : tout appel au gateway passe par ici. */
42
+ function gatewayFetch(path, init = {}) {
43
+ const headers = gatewayHeaders(init.headers);
44
+ return fetch(`${GATEWAY_URL}${path}`, headers ? { ...init, headers } : init);
45
+ }
46
+
47
+ /** Un refus d'authentification mérite mieux qu'un `HTTP 401` nu. */
48
+ function isAuthStatus(status) {
49
+ return status === 401 || status === 403;
50
+ }
51
+
52
+ function authErrorText(status) {
53
+ return GATEWAY_TOKEN
54
+ ? `Accès refusé par le gateway (HTTP ${status}) : le GATEWAY_TOKEN fourni est invalide, ` +
55
+ `révoqué, ou ne correspond pas à cette gateway. Régénérez-le puis mettez à jour la ` +
56
+ `variable GATEWAY_TOKEN dans la configuration de ce serveur MCP.`
57
+ : `Accès refusé par le gateway (HTTP ${status}) : aucun GATEWAY_TOKEN n'est défini alors ` +
58
+ `que ${GATEWAY_URL} en exige un. Ajoutez GATEWAY_TOKEN dans le bloc « env » de la ` +
59
+ `configuration de ce serveur MCP.`;
60
+ }
61
+
62
+ /** Message d'erreur d'un appel outil — inchangé hors 401/403. */
63
+ function gatewayErrorText(status, body) {
64
+ return isAuthStatus(status) ? authErrorText(status) : `Gateway error ${status}: ${body}`;
65
+ }
66
+
67
+ /** Message d'une erreur relancée — inchangé hors 401/403. */
68
+ function gatewayThrowText(status) {
69
+ return isAuthStatus(status) ? authErrorText(status) : `HTTP ${status}`;
70
+ }
71
+
16
72
  // --- MCP Server ---
17
73
 
74
+ // Le gateway (mode serveur HTTP) peut être paramétré (EXPOSE_AT_AGENT_STARTUP) pour
75
+ // pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
76
+ // sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
77
+ // façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
78
+ // OAuth, etc.), on continue sans pré-exposition.
79
+ async function fetchProxyTools() {
80
+ try {
81
+ const res = await gatewayFetch('/startup-exposition');
82
+ if (!res.ok) {
83
+ // La pré-exposition reste optionnelle : on n'interrompt pas le démarrage.
84
+ // Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
85
+ // maintenant évite de chercher plus tard. Sans le token, évidemment.
86
+ if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
87
+ return [];
88
+ }
89
+ const { proxyTools } = await res.json();
90
+ return proxyTools ?? [];
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ const proxyTools = await fetchProxyTools();
97
+ if (proxyTools.length) {
98
+ console.error(`[mcp-mode] ${proxyTools.length} proxy tool(s) exposed directly from gateway`);
99
+ }
100
+
18
101
  const server = new Server(
19
102
  { name: 'mcp-http-gateway-mcp', version: '1.0.0' },
20
103
  { capabilities: { tools: {} } }
@@ -52,15 +135,32 @@ const tools = [
52
135
  },
53
136
  ];
54
137
 
55
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools }));
138
+ const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
139
+ const allTools = [...tools, ...proxyTools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema }))];
140
+
141
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
56
142
 
57
143
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
58
144
  const { name, arguments: args = {} } = request.params;
59
145
 
60
146
  try {
147
+ const proxy = proxyIndex.get(name);
148
+ if (proxy) {
149
+ const res = await gatewayFetch(`${proxy.route}/tools/${proxy.toolName}`, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify(args ?? {}),
153
+ });
154
+ if (!res.ok) {
155
+ const text = await res.text();
156
+ return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
157
+ }
158
+ return await res.json();
159
+ }
160
+
61
161
  if (name === 'list_mcps') {
62
- const res = await fetch(`${GATEWAY_URL}/mcp-definitions`);
63
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
162
+ const res = await gatewayFetch('/mcp-definitions');
163
+ if (!res.ok) throw new Error(gatewayThrowText(res.status));
64
164
  const definitions = await res.json();
65
165
  const text = definitions
66
166
  .map(m => `- ${m.id} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
@@ -69,22 +169,22 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
69
169
  }
70
170
 
71
171
  if (name === 'list_tools') {
72
- const res = await fetch(`${GATEWAY_URL}${args.route}/tools`);
73
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
172
+ const res = await gatewayFetch(`${args.route}/tools`);
173
+ if (!res.ok) throw new Error(gatewayThrowText(res.status));
74
174
  const toolsList = await res.json();
75
- const text = JSON.stringify(toolsList, null, 2);
175
+ const text = JSON.stringify(toolsList);
76
176
  return { content: [{ type: 'text', text }] };
77
177
  }
78
178
 
79
179
  if (name === 'call_tool') {
80
- const res = await fetch(`${GATEWAY_URL}${args.route}/tools/${args.tool_name}`, {
180
+ const res = await gatewayFetch(`${args.route}/tools/${args.tool_name}`, {
81
181
  method: 'POST',
82
182
  headers: { 'Content-Type': 'application/json' },
83
183
  body: JSON.stringify(args.arguments ?? {}),
84
184
  });
85
185
  if (!res.ok) {
86
186
  const text = await res.text();
87
- return { content: [{ type: 'text', text: `Gateway error ${res.status}: ${text}` }], isError: true };
187
+ return { content: [{ type: 'text', text: gatewayErrorText(res.status, text) }], isError: true };
88
188
  }
89
189
  return await res.json();
90
190
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-http-gateway",
3
- "version": "2.0.7",
3
+ "version": "2.0.8",
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",
package/server.js CHANGED
@@ -14,6 +14,7 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
14
14
  import { initDb, getOrComputeToolEmbedding, getAllToolEmbeddings } from './embeddings/db.js';
15
15
  import { getEmbedding, cosineSimilarity } from './embeddings/vectorizer.js';
16
16
  import { registerMcpEndpoint } from './mcp-endpoint.js';
17
+ import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
17
18
  import { createMcpTokenVerifier } from './auth.js';
18
19
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
19
20
  import { rateLimit } from 'express-rate-limit';
@@ -179,7 +180,7 @@ async function main() {
179
180
  initDb(dbPath);
180
181
 
181
182
  const clients = [];
182
- const registry = []; // { name, route, description, tools }
183
+ const registry = []; // { name, route, description, tools, exposeAtStartup }
183
184
 
184
185
  for (const [name, cfg] of Object.entries(servers)) {
185
186
  const route = cfg.env?.MCP_SERVER_ROUTE;
@@ -188,11 +189,19 @@ async function main() {
188
189
  continue;
189
190
  }
190
191
 
192
+ // Directive gateway déclarée par MCP dans son bloc `env` (comme MCP_SERVER_ROUTE) :
193
+ // pré-expose ou non ses outils à l'agent. Absente → MCP non pré-exposé.
194
+ const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
195
+ const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
196
+ if (!valid) {
197
+ console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
198
+ }
199
+
191
200
  try {
192
201
  const client = await connectMcp(name, cfg);
193
202
  const { tools } = await client.listTools();
194
203
  clients.push(client);
195
- registry.push({ name, route, description: cfg.description ?? null, tools, client });
204
+ registry.push({ name, route, description: cfg.description ?? null, tools, client, exposeAtStartup });
196
205
  registerMcpRoutes(app, route, client, name);
197
206
  for (const tool of tools) {
198
207
  if (!tool.description) continue;
@@ -240,6 +249,25 @@ async function main() {
240
249
  );
241
250
  });
242
251
 
252
+ // Outils des MCP pré-exposés (EXPOSE_AT_AGENT_STARTUP), publiés tels quels dans
253
+ // `tools/list`. Exposés en HTTP pour que le bridge stdio (server.js --mcp /
254
+ // mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
255
+ // la config vit uniquement côté serveur, le bridge se contente de transmettre.
256
+ const proxyTools = buildProxyTools(registry);
257
+
258
+ const preExposed = registry.filter(r => r.exposeAtStartup);
259
+ if (preExposed.length) {
260
+ console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map(r => r.name).join(', ')} — ${proxyTools.length} tool(s) published directly in tools/list`);
261
+ }
262
+ const overlong = overlongProxyNames(proxyTools);
263
+ if (overlong.length) {
264
+ console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
265
+ }
266
+
267
+ app.get('/startup-exposition', (req, res) => {
268
+ res.json({ proxyTools });
269
+ });
270
+
243
271
  app.get('/mcps', (req, res) => {
244
272
  const lines = [
245
273
  '# MCP Gateway — Serveurs disponibles',
@@ -256,7 +284,7 @@ async function main() {
256
284
  res.type('text/plain').send(lines.join('\n'));
257
285
  });
258
286
 
259
- registerMcpEndpoint(app, { clients, registry });
287
+ registerMcpEndpoint(app, { clients, registry, proxyTools });
260
288
 
261
289
  const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
262
290
  console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);