@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 +114 -1
- package/mcp-endpoint.js +49 -90
- package/mcp-mode.js +108 -8
- package/package.json +1 -1
- package/server.js +31 -3
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
if (!res.ok) throw new Error(
|
|
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
|
|
73
|
-
if (!res.ok) throw new Error(
|
|
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
|
|
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
|
|
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:
|
|
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
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}`);
|