@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/README.md +707 -512
- package/gateway-exec.js +162 -0
- package/gateway-watch.js +348 -0
- package/http-relay.js +93 -0
- package/mcp-endpoint.js +8 -7
- package/mcp-mode.js +763 -647
- package/mcp-registry.js +234 -0
- package/package.json +58 -54
- package/server.js +120 -114
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('--
|
|
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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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);
|