@imenam/mcp-http-gateway 2.0.15 → 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 +46 -2
- package/mcp-endpoint.js +8 -7
- package/mcp-mode.js +59 -18
- package/mcp-registry.js +234 -0
- package/package.json +3 -2
- package/server.js +105 -126
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ GATEWAY_CONFIG_DIR=./config
|
|
|
52
52
|
|---|---|---|
|
|
53
53
|
| `GATEWAY_PORT` | `3500` | Port HTTP du gateway |
|
|
54
54
|
| `GATEWAY_CONFIG_DIR` | `./config` (relatif au cwd) | Dossier contenant `mcp-servers.json` |
|
|
55
|
+
| `GATEWAY_ADMIN_TOKEN` | — | Active `POST /admin/reload` et en est le jeton. Absent, la route n'existe pas. Voir [Rechargement à chaud](#rechargement-à-chaud). |
|
|
55
56
|
| `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). |
|
|
56
57
|
|
|
57
58
|
### `mcp-servers.json`
|
|
@@ -105,6 +106,47 @@ npm start
|
|
|
105
106
|
|
|
106
107
|
Le gateway connecte chaque MCP configuré et démarre le serveur HTTP. Les MCPs en échec au démarrage sont ignorés sans bloquer les autres.
|
|
107
108
|
|
|
109
|
+
## Rechargement à chaud
|
|
110
|
+
|
|
111
|
+
Avec `GATEWAY_ADMIN_TOKEN` défini, le gateway relit `mcp-servers.json` sur demande, sans redémarrer :
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
curl -X POST -H "X-Gateway-Admin-Token: $GATEWAY_ADMIN_TOKEN" http://127.0.0.1:3500/admin/reload
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Chaque MCP est comparé à sa configuration en service :
|
|
118
|
+
|
|
119
|
+
| Changement | Effet |
|
|
120
|
+
|---|---|
|
|
121
|
+
| MCP retiré du fichier | son processus est arrêté, sa route disparaît |
|
|
122
|
+
| MCP ajouté, ou en échec jusque-là | il est lancé |
|
|
123
|
+
| `command`, `args` ou une variable de `env` modifiés | **seul ce MCP** est relancé |
|
|
124
|
+
| `description` ou `EXPOSE_AT_AGENT_STARTUP` seuls modifiés | mis à jour sans relance |
|
|
125
|
+
| rien | rien |
|
|
126
|
+
|
|
127
|
+
Les autres MCP ne sont pas touchés : leurs processus, leurs sessions et leurs appels en cours continuent.
|
|
128
|
+
|
|
129
|
+
La réponse donne le résultat MCP par MCP :
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"revision": "5ecfab70.3",
|
|
134
|
+
"results": [
|
|
135
|
+
{ "name": "documentor", "action": "unchanged", "ok": true },
|
|
136
|
+
{ "name": "github", "action": "restarted", "ok": true },
|
|
137
|
+
{ "name": "scraper", "action": "started", "ok": false, "error": "MCP error -32000: Connection closed" }
|
|
138
|
+
]
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`action` vaut `started`, `restarted`, `stopped`, `updated` ou `unchanged`. Un MCP qui ne démarre pas est signalé par `ok: false` et reste hors service ; le rechargement suivant tente de le relancer. Un fichier illisible est refusé en `400`, et rien n'est modifié.
|
|
143
|
+
|
|
144
|
+
Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'authentification OAuth du gateway.
|
|
145
|
+
|
|
146
|
+
### Révision
|
|
147
|
+
|
|
148
|
+
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
|
+
|
|
108
150
|
## Routes
|
|
109
151
|
|
|
110
152
|
### `GET /mcps`
|
|
@@ -651,13 +693,15 @@ points d'entrée par lesquels un agent atteint le gateway :
|
|
|
651
693
|
- **bridge stdio** (`server.js --mcp`, cas local) — au démarrage, le bridge interroge le
|
|
652
694
|
serveur (`GET /startup-exposition`), publie les outils reçus et route leurs appels vers
|
|
653
695
|
la bonne route. Le bridge ne connaît pas la config : il ne fait que transmettre ce que le
|
|
654
|
-
serveur décide.
|
|
696
|
+
serveur décide. Quand une réponse du gateway porte une nouvelle
|
|
697
|
+
[révision](#révision), le bridge relit `GET /startup-exposition` et envoie
|
|
698
|
+
`notifications/tools/list_changed` à l'agent.
|
|
655
699
|
|
|
656
700
|
---
|
|
657
701
|
|
|
658
702
|
## Ajouter un MCP
|
|
659
703
|
|
|
660
704
|
1. Ajouter l'entrée dans `config/mcp-servers.json` avec `MCP_SERVER_ROUTE` dans `env`
|
|
661
|
-
2.
|
|
705
|
+
2. Appeler `POST /admin/reload` (voir [Rechargement à chaud](#rechargement-à-chaud)), ou redémarrer le gateway
|
|
662
706
|
|
|
663
707
|
Aucune modification du MCP n'est nécessaire.
|
package/mcp-endpoint.js
CHANGED
|
@@ -39,13 +39,14 @@ const tools = [
|
|
|
39
39
|
},
|
|
40
40
|
];
|
|
41
41
|
|
|
42
|
-
function buildMcpServer(
|
|
42
|
+
function buildMcpServer(registry) {
|
|
43
43
|
const server = new Server(
|
|
44
44
|
{ name: 'mcp-http-gateway-remote', version: '1.0.0' },
|
|
45
45
|
{ capabilities: { tools: {} } }
|
|
46
46
|
);
|
|
47
47
|
|
|
48
48
|
// Outils des MCP pré-exposés en ALL : publiés tels quels, appelables sans call_tool.
|
|
49
|
+
const { proxyTools } = registry;
|
|
49
50
|
const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
|
|
50
51
|
const allTools = [...tools, ...proxyTools.map(toolDefinition)];
|
|
51
52
|
|
|
@@ -57,7 +58,7 @@ function buildMcpServer({ registry, proxyTools = [] }) {
|
|
|
57
58
|
try {
|
|
58
59
|
const proxy = proxyIndex.get(name);
|
|
59
60
|
if (proxy) {
|
|
60
|
-
const entry = registry.
|
|
61
|
+
const entry = registry.byRoute(proxy.route);
|
|
61
62
|
if (!entry) {
|
|
62
63
|
return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
|
|
63
64
|
}
|
|
@@ -65,14 +66,14 @@ function buildMcpServer({ registry, proxyTools = [] }) {
|
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
if (name === 'list_mcps') {
|
|
68
|
-
const text = registry
|
|
69
|
+
const text = registry.entries
|
|
69
70
|
.map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
|
|
70
71
|
.join('\n');
|
|
71
72
|
return { content: [{ type: 'text', text }] };
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
if (name === 'list_tools') {
|
|
75
|
-
const entry = registry.
|
|
76
|
+
const entry = registry.byRoute(args.route);
|
|
76
77
|
if (!entry) {
|
|
77
78
|
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
78
79
|
}
|
|
@@ -81,7 +82,7 @@ function buildMcpServer({ registry, proxyTools = [] }) {
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
if (name === 'call_tool') {
|
|
84
|
-
const entry = registry.
|
|
85
|
+
const entry = registry.byRoute(args.route);
|
|
85
86
|
if (!entry) {
|
|
86
87
|
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
87
88
|
}
|
|
@@ -123,7 +124,7 @@ function buildDnsRebindingConfig() {
|
|
|
123
124
|
return { allowedHosts, allowedOrigins };
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
export function registerMcpEndpoint(app,
|
|
127
|
+
export function registerMcpEndpoint(app, registry) {
|
|
127
128
|
app.get('/.well-known/oauth-protected-resource', (req, res) => {
|
|
128
129
|
res.json(getProtectedResourceMetadata());
|
|
129
130
|
});
|
|
@@ -138,7 +139,7 @@ export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
|
|
|
138
139
|
// Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
|
|
139
140
|
// et l'endpoint supporte le scale-out multi-instances.
|
|
140
141
|
app.post('/mcp', async (req, res) => {
|
|
141
|
-
const server = buildMcpServer(
|
|
142
|
+
const server = buildMcpServer(registry);
|
|
142
143
|
const transport = new StreamableHTTPServerTransport({
|
|
143
144
|
sessionIdGenerator: undefined,
|
|
144
145
|
enableJsonResponse: true,
|
package/mcp-mode.js
CHANGED
|
@@ -47,9 +47,11 @@ function gatewayHeaders(base) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/** Le seul point de sortie HTTP du relais : tout appel au gateway passe par ici. */
|
|
50
|
-
function gatewayFetch(path, init = {}) {
|
|
50
|
+
async function gatewayFetch(path, init = {}) {
|
|
51
51
|
const headers = gatewayHeaders(init.headers);
|
|
52
|
-
|
|
52
|
+
const res = await fetch(`${GATEWAY_URL}${path}`, headers ? { ...init, headers } : init);
|
|
53
|
+
noteRevision(res.headers.get('x-gateway-revision'));
|
|
54
|
+
return res;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** Un refus d'authentification mérite mieux qu'un `HTTP 401` nu. */
|
|
@@ -594,7 +596,7 @@ async function toolSchema(route, toolName) {
|
|
|
594
596
|
// pré-exposer les outils de certains MCP directement à l'agent. La config vit uniquement
|
|
595
597
|
// sur le serveur : ce bridge se contente de récupérer ces outils et de les publier, de
|
|
596
598
|
// façon transparente. En cas d'échec (ancien gateway sans la route, gateway protégé par
|
|
597
|
-
// OAuth, etc.), on continue sans pré-exposition.
|
|
599
|
+
// OAuth, etc.), on continue sans pré-exposition. Rend null si les outils n'ont pas pu être lus.
|
|
598
600
|
async function fetchProxyTools() {
|
|
599
601
|
try {
|
|
600
602
|
const res = await gatewayFetch('/startup-exposition');
|
|
@@ -603,23 +605,51 @@ async function fetchProxyTools() {
|
|
|
603
605
|
// Mais un 401/403 ici annonce que TOUS les appels seront refusés — le dire
|
|
604
606
|
// maintenant évite de chercher plus tard. Sans le token, évidemment.
|
|
605
607
|
if (isAuthStatus(res.status)) console.error(`[mcp-mode] ${authErrorText(res.status)}`);
|
|
606
|
-
return
|
|
608
|
+
return null;
|
|
607
609
|
}
|
|
608
610
|
const { proxyTools } = await res.json();
|
|
609
611
|
return proxyTools ?? [];
|
|
610
612
|
} catch {
|
|
611
|
-
return
|
|
613
|
+
return null;
|
|
612
614
|
}
|
|
613
615
|
}
|
|
614
616
|
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
617
|
+
// --- Suivi des changements de configuration du gateway ---
|
|
618
|
+
//
|
|
619
|
+
// Le gateway renvoie la révision de son état dans l'en-tête `X-Gateway-Revision` de chaque
|
|
620
|
+
// réponse ; elle change quand un MCP est ajouté, retiré ou relancé, et à chaque démarrage du
|
|
621
|
+
// gateway. Dès qu'une réponse en porte une nouvelle, le relais relit les outils pré-exposés,
|
|
622
|
+
// oublie les schémas mis en cache et notifie l'agent par `notifications/tools/list_changed`.
|
|
623
|
+
|
|
624
|
+
let knownRevision = null;
|
|
625
|
+
let refreshQueue = Promise.resolve();
|
|
626
|
+
|
|
627
|
+
function noteRevision(revision) {
|
|
628
|
+
if (!revision || revision === knownRevision) return;
|
|
629
|
+
const previous = knownRevision;
|
|
630
|
+
knownRevision = revision;
|
|
631
|
+
if (previous === null) return;
|
|
632
|
+
refreshQueue = refreshQueue.then(() => refreshTools(previous)).catch((err) => {
|
|
633
|
+
console.error(`[mcp-mode] Rafraîchissement des outils impossible : ${err.message}`);
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async function refreshTools(previousRevision) {
|
|
638
|
+
routeSchemas.clear();
|
|
639
|
+
const next = await fetchProxyTools();
|
|
640
|
+
if (next === null) {
|
|
641
|
+
// Révision remise à l'ancienne valeur : la prochaine réponse du gateway relancera la lecture.
|
|
642
|
+
knownRevision = previousRevision;
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
setProxyTools(next);
|
|
646
|
+
await server.sendToolListChanged();
|
|
647
|
+
console.error(`[mcp-mode] Configuration du gateway modifiée (révision ${knownRevision}) : ${next.length} outil(s) pré-exposé(s)`);
|
|
618
648
|
}
|
|
619
649
|
|
|
620
650
|
const server = new Server(
|
|
621
651
|
{ name: 'mcp-http-gateway-mcp', version: '1.0.0' },
|
|
622
|
-
{ capabilities: { tools: {} } }
|
|
652
|
+
{ capabilities: { tools: { listChanged: true } } }
|
|
623
653
|
);
|
|
624
654
|
|
|
625
655
|
const tools = [
|
|
@@ -654,15 +684,26 @@ const tools = [
|
|
|
654
684
|
},
|
|
655
685
|
];
|
|
656
686
|
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
687
|
+
let proxyIndex = new Map();
|
|
688
|
+
let allTools = tools;
|
|
689
|
+
|
|
690
|
+
function setProxyTools(proxyTools) {
|
|
691
|
+
proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
|
|
692
|
+
allTools = [
|
|
693
|
+
...tools,
|
|
694
|
+
...proxyTools.map(({ name, description, inputSchema }) => ({
|
|
695
|
+
name,
|
|
696
|
+
description,
|
|
697
|
+
inputSchema: schemaForAgent(inputSchema),
|
|
698
|
+
})),
|
|
699
|
+
];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const initialProxyTools = (await fetchProxyTools()) ?? [];
|
|
703
|
+
setProxyTools(initialProxyTools);
|
|
704
|
+
if (initialProxyTools.length) {
|
|
705
|
+
console.error(`[mcp-mode] ${initialProxyTools.length} proxy tool(s) exposed directly from gateway`);
|
|
706
|
+
}
|
|
666
707
|
|
|
667
708
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
|
|
668
709
|
|
package/mcp-registry.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
|
+
import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
|
|
5
|
+
import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix } from './http-relay.js';
|
|
6
|
+
|
|
7
|
+
// --- Registre des MCP hébergés par le gateway ---
|
|
8
|
+
//
|
|
9
|
+
// Le registre est la seule source de vérité sur les MCP en service : les routes REST, le
|
|
10
|
+
// relais HTTP, l'endpoint /mcp et la pré-exposition le consultent à chaque requête.
|
|
11
|
+
//
|
|
12
|
+
// `apply(servers)` aligne le registre sur une configuration `mcpServers` :
|
|
13
|
+
// - MCP absent de la configuration → son processus est arrêté ;
|
|
14
|
+
// - MCP nouveau, ou pas en service → il est lancé ;
|
|
15
|
+
// - command, args ou env modifiés → seul ce MCP est relancé ;
|
|
16
|
+
// - description ou EXPOSE_AT_AGENT_STARTUP seuls modifiés → mis à jour sans relance.
|
|
17
|
+
// Les autres MCP ne sont pas touchés.
|
|
18
|
+
//
|
|
19
|
+
// Chaque changement effectif incrémente la révision, que le gateway renvoie dans l'en-tête
|
|
20
|
+
// `X-Gateway-Revision` : le relais stdio (mcp-mode.js) s'en sert pour rafraîchir ses outils.
|
|
21
|
+
|
|
22
|
+
export const REVISION_HEADER = 'X-Gateway-Revision';
|
|
23
|
+
|
|
24
|
+
// Variables système minimales transmises aux MCPs enfants.
|
|
25
|
+
// On exclut délibérément toutes les variables propres au gateway
|
|
26
|
+
// (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
|
|
27
|
+
const CHILD_ENV_WHITELIST = new Set([
|
|
28
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
|
|
29
|
+
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
30
|
+
'TEMP', 'TMP', 'TMPDIR',
|
|
31
|
+
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
32
|
+
'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
|
|
33
|
+
'COMSPEC', 'PATHEXT', 'USERNAME',
|
|
34
|
+
'PROGRAMFILES', 'NODE_PATH',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Directives lues par le gateway seul : les modifier ne justifie pas de relancer le MCP.
|
|
38
|
+
const GATEWAY_ONLY_KEYS = new Set(['EXPOSE_AT_AGENT_STARTUP']);
|
|
39
|
+
|
|
40
|
+
function buildChildEnv(cfgEnv = {}) {
|
|
41
|
+
const safeEnv = Object.fromEntries(
|
|
42
|
+
Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
|
|
43
|
+
);
|
|
44
|
+
return { ...safeEnv, ...cfgEnv };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function connectMcp(name, cfg) {
|
|
48
|
+
const transport = new StdioClientTransport({
|
|
49
|
+
command: cfg.command,
|
|
50
|
+
args: cfg.args ?? [],
|
|
51
|
+
env: buildChildEnv(cfg.env),
|
|
52
|
+
});
|
|
53
|
+
const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
|
|
54
|
+
await client.connect(transport);
|
|
55
|
+
console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
|
|
56
|
+
return client;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Empreinte de ce qui définit le processus enfant : la changer impose de le relancer. */
|
|
60
|
+
function processKey(cfg) {
|
|
61
|
+
const env = Object.entries(cfg.env ?? {})
|
|
62
|
+
.filter(([key]) => !GATEWAY_ONLY_KEYS.has(key))
|
|
63
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
64
|
+
return JSON.stringify([cfg.command, cfg.args ?? [], env]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lit les directives gateway d'un MCP. Lève une erreur si le MCP ne peut pas être servi.
|
|
69
|
+
*/
|
|
70
|
+
function readDirectives(name, cfg) {
|
|
71
|
+
const route = cfg.env?.MCP_SERVER_ROUTE;
|
|
72
|
+
if (!route) throw new Error('no MCP_SERVER_ROUTE defined');
|
|
73
|
+
|
|
74
|
+
const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
|
|
75
|
+
const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
|
|
76
|
+
if (!valid) {
|
|
77
|
+
console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
|
|
81
|
+
const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
|
|
82
|
+
if (!relayPortValid) {
|
|
83
|
+
throw new Error(`invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { route, exposeAtStartup, relayPort, description: cfg.description ?? null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class McpRegistry {
|
|
90
|
+
/** Entrées en service, dans l'ordre de la configuration. */
|
|
91
|
+
entries = [];
|
|
92
|
+
proxyTools = [];
|
|
93
|
+
/** Préfixes `<route>/http` des MCP qui déclarent un relais HTTP. */
|
|
94
|
+
relayPrefixes = new Set();
|
|
95
|
+
|
|
96
|
+
#bootId = crypto.randomUUID().slice(0, 8);
|
|
97
|
+
#generation = 0;
|
|
98
|
+
#queue = Promise.resolve();
|
|
99
|
+
#onToolsLoaded;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {{ onToolsLoaded?: (name: string, tools: object[]) => void }} options
|
|
103
|
+
* `onToolsLoaded` est appelé à chaque lancement d'un MCP, avec ses outils.
|
|
104
|
+
*/
|
|
105
|
+
constructor({ onToolsLoaded } = {}) {
|
|
106
|
+
this.#onToolsLoaded = onToolsLoaded ?? (() => {});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Identifiant de l'état servi : change à chaque démarrage du gateway et à chaque changement effectif. */
|
|
110
|
+
get revision() {
|
|
111
|
+
return `${this.#bootId}.${this.#generation}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
byRoute(route) {
|
|
115
|
+
return this.entries.find((entry) => entry.route === route) ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
byName(name) {
|
|
119
|
+
return this.entries.find((entry) => entry.name === name) ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Aligne le registre sur `servers` (le bloc `mcpServers` de la configuration).
|
|
124
|
+
* Les appels sont sérialisés. Rend une ligne par MCP concerné :
|
|
125
|
+
* `{ name, action, ok, error? }`, `action` ∈ started | restarted | stopped | updated | unchanged.
|
|
126
|
+
*/
|
|
127
|
+
apply(servers) {
|
|
128
|
+
const run = this.#queue.then(() => this.#apply(servers));
|
|
129
|
+
this.#queue = run.catch(() => {});
|
|
130
|
+
return run;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async closeAll() {
|
|
134
|
+
await Promise.allSettled(this.entries.map((entry) => entry.client.close()));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async #apply(servers) {
|
|
138
|
+
const results = [];
|
|
139
|
+
const wanted = new Map(Object.entries(servers ?? {}));
|
|
140
|
+
const restarting = new Set();
|
|
141
|
+
let changed = false;
|
|
142
|
+
|
|
143
|
+
// Arrêts d'abord : un MCP relancé ou une route reprise ne doivent pas croiser l'ancien processus.
|
|
144
|
+
for (const entry of [...this.entries]) {
|
|
145
|
+
const cfg = wanted.get(entry.name);
|
|
146
|
+
if (cfg && processKey(cfg) === entry.processKey) continue;
|
|
147
|
+
await this.#stop(entry);
|
|
148
|
+
changed = true;
|
|
149
|
+
if (cfg) restarting.add(entry.name);
|
|
150
|
+
else results.push({ name: entry.name, action: 'stopped', ok: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [name, cfg] of wanted) {
|
|
154
|
+
const current = this.byName(name);
|
|
155
|
+
|
|
156
|
+
if (current) {
|
|
157
|
+
const { description, exposeAtStartup } = readDirectives(name, cfg);
|
|
158
|
+
const updated = current.description !== description || current.exposeAtStartup !== exposeAtStartup;
|
|
159
|
+
current.description = description;
|
|
160
|
+
current.exposeAtStartup = exposeAtStartup;
|
|
161
|
+
if (updated) changed = true;
|
|
162
|
+
results.push({ name, action: updated ? 'updated' : 'unchanged', ok: true });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const action = restarting.has(name) ? 'restarted' : 'started';
|
|
167
|
+
try {
|
|
168
|
+
await this.#start(name, cfg);
|
|
169
|
+
changed = true;
|
|
170
|
+
results.push({ name, action, ok: true });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[gateway] Failed to start "${name}": ${err.message}`);
|
|
173
|
+
results.push({ name, action, ok: false, error: err.message });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ordre de la configuration, conservé pour list_mcps, /mcps et la pré-exposition.
|
|
178
|
+
const order = [...wanted.keys()];
|
|
179
|
+
this.entries.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
|
180
|
+
|
|
181
|
+
if (changed) {
|
|
182
|
+
this.#generation += 1;
|
|
183
|
+
this.#refreshDerived();
|
|
184
|
+
}
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async #start(name, cfg) {
|
|
189
|
+
const directives = readDirectives(name, cfg);
|
|
190
|
+
if (this.byRoute(directives.route)) {
|
|
191
|
+
throw new Error(`route ${directives.route} already served by "${this.byRoute(directives.route).name}"`);
|
|
192
|
+
}
|
|
193
|
+
const client = await connectMcp(name, cfg);
|
|
194
|
+
let tools;
|
|
195
|
+
try {
|
|
196
|
+
({ tools } = await client.listTools());
|
|
197
|
+
} catch (err) {
|
|
198
|
+
await client.close().catch(() => {});
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
this.entries.push({ name, ...directives, tools, client, processKey: processKey(cfg) });
|
|
202
|
+
if (directives.relayPort) {
|
|
203
|
+
console.log(`[gateway] HTTP relay: ${relayPrefix(directives.route)}/* → 127.0.0.1:${directives.relayPort} (${name})`);
|
|
204
|
+
}
|
|
205
|
+
this.#onToolsLoaded(name, tools);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async #stop(entry) {
|
|
209
|
+
this.entries = this.entries.filter((e) => e !== entry);
|
|
210
|
+
try {
|
|
211
|
+
await entry.client.close();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(`[gateway] Error while closing "${entry.name}":`, err.message);
|
|
214
|
+
}
|
|
215
|
+
console.log(`[gateway] Stopped: ${entry.name}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#refreshDerived() {
|
|
219
|
+
this.relayPrefixes = new Set(
|
|
220
|
+
this.entries.filter((e) => e.relayPort).map((e) => relayPrefix(e.route))
|
|
221
|
+
);
|
|
222
|
+
this.proxyTools = buildProxyTools(this.entries);
|
|
223
|
+
|
|
224
|
+
const preExposed = this.entries.filter((e) => e.exposeAtStartup);
|
|
225
|
+
if (preExposed.length) {
|
|
226
|
+
console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map((e) => e.name).join(', ')} — ${this.proxyTools.length} tool(s) published directly in tools/list`);
|
|
227
|
+
}
|
|
228
|
+
const overlong = overlongProxyNames(this.proxyTools);
|
|
229
|
+
if (overlong.length) {
|
|
230
|
+
console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
console.log(`[gateway] Revision ${this.revision} — ${this.entries.length} MCP(s) in service`);
|
|
233
|
+
}
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/mcp-http-gateway",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.16",
|
|
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",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"startup-exposition.js",
|
|
18
18
|
"http-relay.js",
|
|
19
19
|
"gateway-exec.js",
|
|
20
|
-
"gateway-watch.js"
|
|
20
|
+
"gateway-watch.js",
|
|
21
|
+
"mcp-registry.js"
|
|
21
22
|
],
|
|
22
23
|
"scripts": {
|
|
23
24
|
"start": "node server.js",
|
package/server.js
CHANGED
|
@@ -13,17 +13,16 @@ if (process.argv.includes('--watch')) {
|
|
|
13
13
|
// Process stays alive: the MCP transport keeps stdin open until the client disconnects
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
import crypto from 'crypto';
|
|
16
17
|
import { readFileSync } from 'fs';
|
|
17
18
|
import path from 'path';
|
|
18
19
|
import express from 'express';
|
|
19
|
-
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
20
|
-
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
21
20
|
import { initDb, getOrComputeToolEmbedding, getAllToolEmbeddings } from './embeddings/db.js';
|
|
22
21
|
import { getEmbedding, cosineSimilarity } from './embeddings/vectorizer.js';
|
|
23
22
|
import { registerMcpEndpoint } from './mcp-endpoint.js';
|
|
24
|
-
import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
|
|
25
23
|
import { createMcpTokenVerifier } from './auth.js';
|
|
26
|
-
import {
|
|
24
|
+
import { relayPrefix, isRelayPath, createHttpRelay } from './http-relay.js';
|
|
25
|
+
import { McpRegistry, REVISION_HEADER } from './mcp-registry.js';
|
|
27
26
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
28
27
|
import { rateLimit } from 'express-rate-limit';
|
|
29
28
|
|
|
@@ -38,65 +37,61 @@ function normalizeCfg(cfg) {
|
|
|
38
37
|
return { ...cfg, command, args, env };
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return { ...safeEnv, ...cfgEnv };
|
|
59
|
-
}
|
|
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
|
+
}
|
|
60
57
|
|
|
61
|
-
|
|
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
|
+
}
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
};
|
|
73
82
|
}
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
const { tools } = await client.listTools();
|
|
81
|
-
res.json(tools);
|
|
82
|
-
} catch (err) {
|
|
83
|
-
console.error(`[${name}] listTools error:`, err.message);
|
|
84
|
-
res.status(500).json({ error: err.message });
|
|
85
|
-
}
|
|
86
|
-
});
|
|
84
|
+
function readServers(configPath) {
|
|
85
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
86
|
+
return config.mcpServers ?? {};
|
|
87
|
+
}
|
|
87
88
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
res.json(result);
|
|
95
|
-
} catch (err) {
|
|
96
|
-
console.error(`[${name}] callTool(${req.params.toolName}) error:`, err.message);
|
|
97
|
-
res.status(500).json({ error: err.message });
|
|
98
|
-
}
|
|
99
|
-
});
|
|
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);
|
|
100
95
|
}
|
|
101
96
|
|
|
102
97
|
// --- Bootstrap ---
|
|
@@ -113,8 +108,18 @@ async function main() {
|
|
|
113
108
|
? path.resolve(process.env.GATEWAY_CONFIG_DIR)
|
|
114
109
|
: path.join(process.cwd(), 'config');
|
|
115
110
|
const configPath = path.join(configDir, 'mcp-servers.json');
|
|
116
|
-
const
|
|
117
|
-
|
|
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
|
+
});
|
|
118
123
|
|
|
119
124
|
const app = express();
|
|
120
125
|
if (process.env.TRUST_PROXY) {
|
|
@@ -127,10 +132,39 @@ async function main() {
|
|
|
127
132
|
// qui repartirait sinon en « 413 Payload Too Large » sans explication.
|
|
128
133
|
const BODY_LIMIT = process.env.GATEWAY_BODY_LIMIT ?? '32mb';
|
|
129
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
|
+
|
|
130
165
|
// Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
|
|
131
|
-
const relayPrefixes = new Set();
|
|
132
166
|
app.use((req, res, next) =>
|
|
133
|
-
isRelayPath(relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
|
|
167
|
+
isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
|
|
134
168
|
);
|
|
135
169
|
|
|
136
170
|
const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
|
|
@@ -196,53 +230,8 @@ async function main() {
|
|
|
196
230
|
const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
|
|
197
231
|
initDb(dbPath);
|
|
198
232
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
for (const [name, cfg] of Object.entries(servers)) {
|
|
203
|
-
const route = cfg.env?.MCP_SERVER_ROUTE;
|
|
204
|
-
if (!route) {
|
|
205
|
-
console.warn(`[gateway] Skipping "${name}": no MCP_SERVER_ROUTE defined`);
|
|
206
|
-
continue;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Directive gateway déclarée par MCP dans son bloc `env` (comme MCP_SERVER_ROUTE) :
|
|
210
|
-
// pré-expose ou non ses outils à l'agent. Absente → MCP non pré-exposé.
|
|
211
|
-
const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
|
|
212
|
-
const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
|
|
213
|
-
if (!valid) {
|
|
214
|
-
console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
|
|
218
|
-
const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
|
|
219
|
-
if (!relayPortValid) {
|
|
220
|
-
console.error(`[gateway] Skipping "${name}": invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
const client = await connectMcp(name, cfg);
|
|
226
|
-
const { tools } = await client.listTools();
|
|
227
|
-
clients.push(client);
|
|
228
|
-
registry.push({ name, route, description: cfg.description ?? null, tools, client, exposeAtStartup, relayPort });
|
|
229
|
-
registerMcpRoutes(app, route, client, name);
|
|
230
|
-
if (relayPort) {
|
|
231
|
-
const prefix = relayPrefix(route);
|
|
232
|
-
relayPrefixes.add(prefix);
|
|
233
|
-
app.use(prefix, createHttpRelay({ name, port: relayPort }));
|
|
234
|
-
console.log(`[gateway] HTTP relay: ${prefix}/* → 127.0.0.1:${relayPort} (${name})`);
|
|
235
|
-
}
|
|
236
|
-
for (const tool of tools) {
|
|
237
|
-
if (!tool.description) continue;
|
|
238
|
-
getOrComputeToolEmbedding(name, tool, getEmbedding).catch(err =>
|
|
239
|
-
console.error(`[gateway] Embedding error for ${name}::${tool.name}:`, err.message)
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
} catch (err) {
|
|
243
|
-
console.error(`[gateway] Failed to connect "${name}":`, err.message);
|
|
244
|
-
}
|
|
245
|
-
}
|
|
233
|
+
await registry.apply(servers);
|
|
234
|
+
app.use(mcpRoutes(registry));
|
|
246
235
|
|
|
247
236
|
app.get('/tools/search', searchLimiter, async (req, res) => {
|
|
248
237
|
const q = req.query.q?.trim();
|
|
@@ -251,12 +240,13 @@ async function main() {
|
|
|
251
240
|
try {
|
|
252
241
|
const queryEmbedding = await getEmbedding(q);
|
|
253
242
|
const results = getAllToolEmbeddings()
|
|
243
|
+
.filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
|
|
254
244
|
.map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
|
|
255
245
|
.sort((a, b) => b.score - a.score)
|
|
256
246
|
.slice(0, limit)
|
|
257
247
|
.map(({ mcp_name, tool_name, description, score }) => ({
|
|
258
248
|
mcp_name,
|
|
259
|
-
mcp_description: registry.
|
|
249
|
+
mcp_description: registry.byName(mcp_name)?.description ?? null,
|
|
260
250
|
tool_name,
|
|
261
251
|
description,
|
|
262
252
|
score,
|
|
@@ -270,7 +260,7 @@ async function main() {
|
|
|
270
260
|
|
|
271
261
|
app.get('/mcp-definitions', (req, res) => {
|
|
272
262
|
res.json(
|
|
273
|
-
registry.map(({ name, route, description, tools }) => ({
|
|
263
|
+
registry.entries.map(({ name, route, description, tools }) => ({
|
|
274
264
|
id: name,
|
|
275
265
|
route,
|
|
276
266
|
description,
|
|
@@ -283,19 +273,8 @@ async function main() {
|
|
|
283
273
|
// `tools/list`. Exposés en HTTP pour que le bridge stdio (server.js --mcp /
|
|
284
274
|
// mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
|
|
285
275
|
// la config vit uniquement côté serveur, le bridge se contente de transmettre.
|
|
286
|
-
const proxyTools = buildProxyTools(registry);
|
|
287
|
-
|
|
288
|
-
const preExposed = registry.filter(r => r.exposeAtStartup);
|
|
289
|
-
if (preExposed.length) {
|
|
290
|
-
console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map(r => r.name).join(', ')} — ${proxyTools.length} tool(s) published directly in tools/list`);
|
|
291
|
-
}
|
|
292
|
-
const overlong = overlongProxyNames(proxyTools);
|
|
293
|
-
if (overlong.length) {
|
|
294
|
-
console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
276
|
app.get('/startup-exposition', (req, res) => {
|
|
298
|
-
res.json({ proxyTools });
|
|
277
|
+
res.json({ proxyTools: registry.proxyTools, revision: registry.revision });
|
|
299
278
|
});
|
|
300
279
|
|
|
301
280
|
app.get('/mcps', (req, res) => {
|
|
@@ -307,7 +286,7 @@ async function main() {
|
|
|
307
286
|
'',
|
|
308
287
|
'## Serveurs',
|
|
309
288
|
'',
|
|
310
|
-
...registry.map(({ name, route, description, relayPort }) =>
|
|
289
|
+
...registry.entries.map(({ name, route, description, relayPort }) =>
|
|
311
290
|
`### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
|
|
312
291
|
(relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
|
|
313
292
|
),
|
|
@@ -315,7 +294,7 @@ async function main() {
|
|
|
315
294
|
res.type('text/plain').send(lines.join('\n'));
|
|
316
295
|
});
|
|
317
296
|
|
|
318
|
-
registerMcpEndpoint(app,
|
|
297
|
+
registerMcpEndpoint(app, registry);
|
|
319
298
|
|
|
320
299
|
const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
|
|
321
300
|
console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
|
|
@@ -328,7 +307,7 @@ async function main() {
|
|
|
328
307
|
|
|
329
308
|
const shutdown = async () => {
|
|
330
309
|
console.log('[gateway] Shutting down...');
|
|
331
|
-
await
|
|
310
|
+
await registry.closeAll();
|
|
332
311
|
process.exit(0);
|
|
333
312
|
};
|
|
334
313
|
|