@imenam/mcp-http-gateway 2.0.15 → 2.0.19
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 +81 -2
- package/mcp-endpoint.js +24 -8
- package/mcp-mode.js +59 -18
- package/mcp-registry.js +234 -0
- package/package.json +4 -2
- package/scope.js +123 -0
- package/server.js +147 -131
- package/startup-exposition.js +3 -2
package/README.md
CHANGED
|
@@ -52,6 +52,8 @@ 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). |
|
|
56
|
+
| `GATEWAY_SCOPE_SECRET` | — | Impose un périmètre d'accès signé sur chaque requête. Absent, toute requête voit tous les MCP et tous les outils. Voir [Périmètre d'accès](#périmètre-daccès). |
|
|
55
57
|
| `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
58
|
|
|
57
59
|
### `mcp-servers.json`
|
|
@@ -105,6 +107,81 @@ npm start
|
|
|
105
107
|
|
|
106
108
|
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
109
|
|
|
110
|
+
## Rechargement à chaud
|
|
111
|
+
|
|
112
|
+
Avec `GATEWAY_ADMIN_TOKEN` défini, le gateway relit `mcp-servers.json` sur demande, sans redémarrer :
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
curl -X POST -H "X-Gateway-Admin-Token: $GATEWAY_ADMIN_TOKEN" http://127.0.0.1:3500/admin/reload
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Chaque MCP est comparé à sa configuration en service :
|
|
119
|
+
|
|
120
|
+
| Changement | Effet |
|
|
121
|
+
|---|---|
|
|
122
|
+
| MCP retiré du fichier | son processus est arrêté, sa route disparaît |
|
|
123
|
+
| MCP ajouté, ou en échec jusque-là | il est lancé |
|
|
124
|
+
| `command`, `args` ou une variable de `env` modifiés | **seul ce MCP** est relancé |
|
|
125
|
+
| `description` ou `EXPOSE_AT_AGENT_STARTUP` seuls modifiés | mis à jour sans relance |
|
|
126
|
+
| rien | rien |
|
|
127
|
+
|
|
128
|
+
Les autres MCP ne sont pas touchés : leurs processus, leurs sessions et leurs appels en cours continuent.
|
|
129
|
+
|
|
130
|
+
La réponse donne le résultat MCP par MCP :
|
|
131
|
+
|
|
132
|
+
```json
|
|
133
|
+
{
|
|
134
|
+
"revision": "5ecfab70.3",
|
|
135
|
+
"results": [
|
|
136
|
+
{ "name": "documentor", "action": "unchanged", "ok": true },
|
|
137
|
+
{ "name": "github", "action": "restarted", "ok": true },
|
|
138
|
+
{ "name": "scraper", "action": "started", "ok": false, "error": "MCP error -32000: Connection closed" }
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`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é.
|
|
144
|
+
|
|
145
|
+
Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'authentification OAuth du gateway.
|
|
146
|
+
|
|
147
|
+
### Révision
|
|
148
|
+
|
|
149
|
+
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.
|
|
150
|
+
|
|
151
|
+
Sous [périmètre d'accès](#périmètre-daccès), la révision est suffixée de l'empreinte du périmètre (`5ecfab70.3~03d09cc8`) : un changement de droits change la révision, et le relais relit les outils de l'agent comme après un rechargement.
|
|
152
|
+
|
|
153
|
+
## Périmètre d'accès
|
|
154
|
+
|
|
155
|
+
Le gateway ne sait pas qui l'appelle. Quand il est placé derrière un frontal qui authentifie ses utilisateurs (le hub `mcp-gateway-manager`, par exemple), c'est ce frontal qui décide de ce que chaque requête peut atteindre, et le gateway qui l'applique.
|
|
156
|
+
|
|
157
|
+
Avec `GATEWAY_SCOPE_SECRET` défini, **toute requête** doit porter l'en-tête `X-Gateway-Scope`, signé avec ce secret. Une requête sans en-tête, ou dont la signature est fausse, est refusée en `403`. Seules `POST /admin/reload` (qui a son propre jeton) et `/.well-known/*` y échappent.
|
|
158
|
+
|
|
159
|
+
L'en-tête vaut `<charge>.<signature>` :
|
|
160
|
+
|
|
161
|
+
- `charge` : un objet JSON, encodé en base64url ;
|
|
162
|
+
- `signature` : le HMAC-SHA256 de la charge encodée, avec `GATEWAY_SCOPE_SECRET` pour clé, en base64url.
|
|
163
|
+
|
|
164
|
+
```jsonc
|
|
165
|
+
{ "v": 1, "all": true } // tout le gateway
|
|
166
|
+
{ "v": 1, "mcps": {
|
|
167
|
+
"documentor": "*", // tous les outils de ce MCP, y compris ceux ajoutés plus tard
|
|
168
|
+
"github": ["get_info", "pull_request"] // ces outils seulement
|
|
169
|
+
} }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Les MCP sont désignés par leur nom (la clé de `mcpServers`). Le périmètre s'applique partout :
|
|
173
|
+
|
|
174
|
+
| Point d'accès | MCP hors périmètre | Outil hors périmètre |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `GET /mcps`, `GET /mcp-definitions`, `list_mcps` | absent | absent de `tools` |
|
|
177
|
+
| `GET /{route}/tools`, `list_tools` | `404` / « No MCP found » | absent |
|
|
178
|
+
| `POST /{route}/tools/:toolName`, `call_tool` | `404` / « No MCP found » | `403` / « Tool not allowed » |
|
|
179
|
+
| `/{route}/http/*` | `404` | — (le relais suit le MCP) |
|
|
180
|
+
| `GET /startup-exposition`, `tools/list` de `/mcp` | outils absents | outil absent |
|
|
181
|
+
| `GET /tools/search` | résultats absents | résultat absent |
|
|
182
|
+
|
|
183
|
+
Le module `scope.js` exporte `encodeScope(charge, secret)`, qui fabrique l'en-tête.
|
|
184
|
+
|
|
108
185
|
## Routes
|
|
109
186
|
|
|
110
187
|
### `GET /mcps`
|
|
@@ -651,13 +728,15 @@ points d'entrée par lesquels un agent atteint le gateway :
|
|
|
651
728
|
- **bridge stdio** (`server.js --mcp`, cas local) — au démarrage, le bridge interroge le
|
|
652
729
|
serveur (`GET /startup-exposition`), publie les outils reçus et route leurs appels vers
|
|
653
730
|
la bonne route. Le bridge ne connaît pas la config : il ne fait que transmettre ce que le
|
|
654
|
-
serveur décide.
|
|
731
|
+
serveur décide. Quand une réponse du gateway porte une nouvelle
|
|
732
|
+
[révision](#révision), le bridge relit `GET /startup-exposition` et envoie
|
|
733
|
+
`notifications/tools/list_changed` à l'agent.
|
|
655
734
|
|
|
656
735
|
---
|
|
657
736
|
|
|
658
737
|
## Ajouter un MCP
|
|
659
738
|
|
|
660
739
|
1. Ajouter l'entrée dans `config/mcp-servers.json` avec `MCP_SERVER_ROUTE` dans `env`
|
|
661
|
-
2.
|
|
740
|
+
2. Appeler `POST /admin/reload` (voir [Rechargement à chaud](#rechargement-à-chaud)), ou redémarrer le gateway
|
|
662
741
|
|
|
663
742
|
Aucune modification du MCP n'est nécessaire.
|
package/mcp-endpoint.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
7
7
|
import { getProtectedResourceMetadata } from './auth.js';
|
|
8
8
|
import { toolDefinition } from './startup-exposition.js';
|
|
9
|
+
import { allowsMcp, allowsTool, filterTools } from './scope.js';
|
|
9
10
|
|
|
10
11
|
const tools = [
|
|
11
12
|
{
|
|
@@ -39,16 +40,27 @@ const tools = [
|
|
|
39
40
|
},
|
|
40
41
|
];
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Serveur MCP d'une requête, restreint à son périmètre (voir scope.js) : un MCP hors
|
|
45
|
+
* périmètre est introuvable, un outil hors périmètre n'est ni listé ni appelable.
|
|
46
|
+
*/
|
|
47
|
+
function buildMcpServer(registry, scope) {
|
|
43
48
|
const server = new Server(
|
|
44
49
|
{ name: 'mcp-http-gateway-remote', version: '1.0.0' },
|
|
45
50
|
{ capabilities: { tools: {} } }
|
|
46
51
|
);
|
|
47
52
|
|
|
48
53
|
// Outils des MCP pré-exposés en ALL : publiés tels quels, appelables sans call_tool.
|
|
54
|
+
const proxyTools = registry.proxyTools.filter(t => allowsTool(scope, t.mcpName, t.toolName));
|
|
49
55
|
const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
|
|
50
56
|
const allTools = [...tools, ...proxyTools.map(toolDefinition)];
|
|
51
57
|
|
|
58
|
+
/** Le MCP servi sur cette route, s'il est dans le périmètre. */
|
|
59
|
+
const visibleEntry = (route) => {
|
|
60
|
+
const entry = registry.byRoute(route);
|
|
61
|
+
return entry && allowsMcp(scope, entry.name) ? entry : null;
|
|
62
|
+
};
|
|
63
|
+
|
|
52
64
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
|
|
53
65
|
|
|
54
66
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -57,7 +69,7 @@ function buildMcpServer({ registry, proxyTools = [] }) {
|
|
|
57
69
|
try {
|
|
58
70
|
const proxy = proxyIndex.get(name);
|
|
59
71
|
if (proxy) {
|
|
60
|
-
const entry =
|
|
72
|
+
const entry = visibleEntry(proxy.route);
|
|
61
73
|
if (!entry) {
|
|
62
74
|
return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
|
|
63
75
|
}
|
|
@@ -65,26 +77,30 @@ function buildMcpServer({ registry, proxyTools = [] }) {
|
|
|
65
77
|
}
|
|
66
78
|
|
|
67
79
|
if (name === 'list_mcps') {
|
|
68
|
-
const text = registry
|
|
80
|
+
const text = registry.entries
|
|
81
|
+
.filter(m => allowsMcp(scope, m.name))
|
|
69
82
|
.map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
|
|
70
83
|
.join('\n');
|
|
71
84
|
return { content: [{ type: 'text', text }] };
|
|
72
85
|
}
|
|
73
86
|
|
|
74
87
|
if (name === 'list_tools') {
|
|
75
|
-
const entry =
|
|
88
|
+
const entry = visibleEntry(args.route);
|
|
76
89
|
if (!entry) {
|
|
77
90
|
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
78
91
|
}
|
|
79
|
-
const text = JSON.stringify(entry.tools);
|
|
92
|
+
const text = JSON.stringify(filterTools(scope, entry.name, entry.tools));
|
|
80
93
|
return { content: [{ type: 'text', text }] };
|
|
81
94
|
}
|
|
82
95
|
|
|
83
96
|
if (name === 'call_tool') {
|
|
84
|
-
const entry =
|
|
97
|
+
const entry = visibleEntry(args.route);
|
|
85
98
|
if (!entry) {
|
|
86
99
|
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
87
100
|
}
|
|
101
|
+
if (!allowsTool(scope, entry.name, args.tool_name)) {
|
|
102
|
+
return { content: [{ type: 'text', text: `Tool not allowed: ${args.tool_name}` }], isError: true };
|
|
103
|
+
}
|
|
88
104
|
const result = await entry.client.callTool({
|
|
89
105
|
name: args.tool_name,
|
|
90
106
|
arguments: args.arguments ?? {},
|
|
@@ -123,7 +139,7 @@ function buildDnsRebindingConfig() {
|
|
|
123
139
|
return { allowedHosts, allowedOrigins };
|
|
124
140
|
}
|
|
125
141
|
|
|
126
|
-
export function registerMcpEndpoint(app,
|
|
142
|
+
export function registerMcpEndpoint(app, registry) {
|
|
127
143
|
app.get('/.well-known/oauth-protected-resource', (req, res) => {
|
|
128
144
|
res.json(getProtectedResourceMetadata());
|
|
129
145
|
});
|
|
@@ -138,7 +154,7 @@ export function registerMcpEndpoint(app, { clients, registry, proxyTools }) {
|
|
|
138
154
|
// Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
|
|
139
155
|
// et l'endpoint supporte le scale-out multi-instances.
|
|
140
156
|
app.post('/mcp', async (req, res) => {
|
|
141
|
-
const server = buildMcpServer(
|
|
157
|
+
const server = buildMcpServer(registry, req.scope);
|
|
142
158
|
const transport = new StreamableHTTPServerTransport({
|
|
143
159
|
sessionIdGenerator: undefined,
|
|
144
160
|
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.19",
|
|
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,9 @@
|
|
|
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",
|
|
22
|
+
"scope.js"
|
|
21
23
|
],
|
|
22
24
|
"scripts": {
|
|
23
25
|
"start": "node server.js",
|
package/scope.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// --- Périmètre d'accès d'une requête ---
|
|
4
|
+
//
|
|
5
|
+
// Le périmètre dit quels MCP, et quels outils de chaque MCP, une requête peut atteindre.
|
|
6
|
+
// Il n'est pas calculé ici : c'est le frontal qui a authentifié l'appelant (le hub
|
|
7
|
+
// mcp-gateway-manager) qui le fixe, puis le transmet dans l'en-tête `X-Gateway-Scope`,
|
|
8
|
+
// signé avec le secret partagé `GATEWAY_SCOPE_SECRET`.
|
|
9
|
+
//
|
|
10
|
+
// Sans `GATEWAY_SCOPE_SECRET`, le gateway ne connaît pas de périmètre : toute requête voit
|
|
11
|
+
// tous les MCP et tous les outils. Avec, toute requête doit porter un périmètre valide,
|
|
12
|
+
// sinon elle est refusée en 403.
|
|
13
|
+
//
|
|
14
|
+
// Format de l'en-tête : `<charge>.<signature>`
|
|
15
|
+
// - charge : JSON encodé en base64url ;
|
|
16
|
+
// - signature : HMAC-SHA256 de la charge (telle qu'encodée), en base64url.
|
|
17
|
+
//
|
|
18
|
+
// Charge, version 1 :
|
|
19
|
+
// { "v": 1, "all": true } → tout le gateway
|
|
20
|
+
// { "v": 1, "mcps": { "documentor": "*", → tous les outils du MCP
|
|
21
|
+
// "github": ["get_info", "clone"] } } → ces outils seulement
|
|
22
|
+
// Un MCP absent de `mcps` est invisible : il n'apparaît dans aucune liste et ses routes
|
|
23
|
+
// répondent comme une route inconnue.
|
|
24
|
+
|
|
25
|
+
export const SCOPE_HEADER = 'X-Gateway-Scope';
|
|
26
|
+
|
|
27
|
+
const SCOPE_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
/** Périmètre d'une requête qui voit tout le gateway. */
|
|
30
|
+
export const FULL_SCOPE = Object.freeze({ all: true, mcps: null, digest: null });
|
|
31
|
+
|
|
32
|
+
function sign(encoded, secret) {
|
|
33
|
+
return crypto.createHmac('sha256', secret).update(encoded).digest('base64url');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Construit la valeur de l'en-tête pour une charge donnée. */
|
|
37
|
+
export function encodeScope(payload, secret) {
|
|
38
|
+
const encoded = Buffer.from(JSON.stringify({ v: SCOPE_VERSION, ...payload }), 'utf8').toString('base64url');
|
|
39
|
+
return `${encoded}.${sign(encoded, secret)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function signatureMatches(encoded, presented, secret) {
|
|
43
|
+
const expected = Buffer.from(sign(encoded, secret));
|
|
44
|
+
const actual = Buffer.from(presented);
|
|
45
|
+
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseMcps(raw) {
|
|
49
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
50
|
+
const mcps = new Map();
|
|
51
|
+
for (const [name, tools] of Object.entries(raw)) {
|
|
52
|
+
if (tools === '*') {
|
|
53
|
+
mcps.set(name, '*');
|
|
54
|
+
} else if (Array.isArray(tools) && tools.every((tool) => typeof tool === 'string')) {
|
|
55
|
+
mcps.set(name, new Set(tools));
|
|
56
|
+
} else {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return mcps;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Lit et vérifie l'en-tête de périmètre.
|
|
65
|
+
* Rend `{ ok: true, scope }` ou `{ ok: false, error }` — jamais d'exception.
|
|
66
|
+
*/
|
|
67
|
+
export function readScope(header, secret) {
|
|
68
|
+
if (typeof header !== 'string' || header === '') {
|
|
69
|
+
return { ok: false, error: `Missing ${SCOPE_HEADER} header` };
|
|
70
|
+
}
|
|
71
|
+
const separator = header.indexOf('.');
|
|
72
|
+
if (separator <= 0 || separator === header.length - 1) {
|
|
73
|
+
return { ok: false, error: `Malformed ${SCOPE_HEADER} header` };
|
|
74
|
+
}
|
|
75
|
+
const encoded = header.slice(0, separator);
|
|
76
|
+
if (!signatureMatches(encoded, header.slice(separator + 1), secret)) {
|
|
77
|
+
return { ok: false, error: `Invalid ${SCOPE_HEADER} signature` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let payload;
|
|
81
|
+
try {
|
|
82
|
+
payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
83
|
+
} catch {
|
|
84
|
+
return { ok: false, error: `Unreadable ${SCOPE_HEADER} payload` };
|
|
85
|
+
}
|
|
86
|
+
if (payload?.v !== SCOPE_VERSION) {
|
|
87
|
+
return { ok: false, error: `Unsupported ${SCOPE_HEADER} version: ${payload?.v}` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const digest = crypto.createHash('sha256').update(encoded).digest('hex').slice(0, 8);
|
|
91
|
+
if (payload.all === true) return { ok: true, scope: { all: true, mcps: null, digest } };
|
|
92
|
+
|
|
93
|
+
const mcps = parseMcps(payload.mcps);
|
|
94
|
+
if (!mcps) return { ok: false, error: `Invalid ${SCOPE_HEADER} payload` };
|
|
95
|
+
return { ok: true, scope: { all: false, mcps, digest } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Le MCP est-il visible dans ce périmètre ? */
|
|
99
|
+
export function allowsMcp(scope, mcpName) {
|
|
100
|
+
return scope.all || scope.mcps.has(mcpName);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** L'outil est-il appelable dans ce périmètre ? */
|
|
104
|
+
export function allowsTool(scope, mcpName, toolName) {
|
|
105
|
+
if (scope.all) return true;
|
|
106
|
+
const tools = scope.mcps.get(mcpName);
|
|
107
|
+
if (tools === undefined) return false;
|
|
108
|
+
return tools === '*' || tools.has(toolName);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Les outils d'un MCP réduits à ceux du périmètre. */
|
|
112
|
+
export function filterTools(scope, mcpName, tools) {
|
|
113
|
+
return (tools ?? []).filter((tool) => allowsTool(scope, mcpName, tool.name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Révision servie à une requête : celle du registre, suffixée de l'empreinte du périmètre.
|
|
118
|
+
* Un changement de droits change donc la révision, ce qui suffit au relais stdio pour
|
|
119
|
+
* relire ses outils.
|
|
120
|
+
*/
|
|
121
|
+
export function scopedRevision(revision, scope) {
|
|
122
|
+
return scope.digest ? `${revision}~${scope.digest}` : revision;
|
|
123
|
+
}
|
package/server.js
CHANGED
|
@@ -13,17 +13,19 @@ 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';
|
|
26
|
+
import {
|
|
27
|
+
FULL_SCOPE, SCOPE_HEADER, allowsMcp, allowsTool, filterTools, readScope, scopedRevision,
|
|
28
|
+
} from './scope.js';
|
|
27
29
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
28
30
|
import { rateLimit } from 'express-rate-limit';
|
|
29
31
|
|
|
@@ -38,65 +40,67 @@ function normalizeCfg(cfg) {
|
|
|
38
40
|
return { ...cfg, command, args, env };
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
43
|
+
// --- Routes des MCP ---
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Routes `<route>/tools`, `<route>/tools/:toolName` et `<route>/http/*` de chaque MCP,
|
|
47
|
+
* résolues sur le registre à chaque requête et restreintes au périmètre de la requête :
|
|
48
|
+
* un MCP hors périmètre répond comme une route inconnue, un outil hors périmètre est
|
|
49
|
+
* absent de la liste et refusé en 403.
|
|
50
|
+
*/
|
|
51
|
+
function mcpRoutes(registry) {
|
|
52
|
+
return async (req, res, next) => {
|
|
53
|
+
for (const entry of registry.entries) {
|
|
54
|
+
const { route, name, client } = entry;
|
|
55
|
+
if (req.path !== route && !req.path.startsWith(`${route}/`)) continue;
|
|
56
|
+
if (!allowsMcp(req.scope, name)) return next();
|
|
57
|
+
const rest = req.path.slice(route.length);
|
|
58
|
+
|
|
59
|
+
if (entry.relayPort && (rest === '/http' || rest.startsWith('/http/'))) {
|
|
60
|
+
req.url = req.url.slice(relayPrefix(route).length) || '/';
|
|
61
|
+
return createHttpRelay({ name, port: entry.relayPort })(req, res);
|
|
62
|
+
}
|
|
60
63
|
|
|
61
|
-
|
|
64
|
+
if (req.method === 'GET' && rest === '/tools') {
|
|
65
|
+
try {
|
|
66
|
+
const { tools } = await client.listTools();
|
|
67
|
+
return res.json(filterTools(req.scope, name, tools));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error(`[${name}] listTools error:`, err.message);
|
|
70
|
+
return res.status(500).json({ error: err.message });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
62
73
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
const toolMatch = /^\/tools\/([^/]+)$/.exec(rest);
|
|
75
|
+
if (req.method === 'POST' && toolMatch) {
|
|
76
|
+
const toolName = decodeURIComponent(toolMatch[1]);
|
|
77
|
+
if (!allowsTool(req.scope, name, toolName)) {
|
|
78
|
+
return res.status(403).json({ error: `Tool not allowed: ${toolName}` });
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const result = await client.callTool({ name: toolName, arguments: req.body ?? {} });
|
|
82
|
+
return res.json(result);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(`[${name}] callTool(${toolName}) error:`, err.message);
|
|
85
|
+
return res.status(500).json({ error: err.message });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return next();
|
|
90
|
+
};
|
|
73
91
|
}
|
|
74
92
|
|
|
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
|
-
});
|
|
93
|
+
function readServers(configPath) {
|
|
94
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
95
|
+
return config.mcpServers ?? {};
|
|
96
|
+
}
|
|
87
97
|
|
|
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
|
-
});
|
|
98
|
+
/** Comparaison à temps constant du jeton d'administration présenté. */
|
|
99
|
+
function adminTokenMatches(presented, expected) {
|
|
100
|
+
if (typeof presented !== 'string') return false;
|
|
101
|
+
const a = crypto.createHash('sha256').update(presented).digest();
|
|
102
|
+
const b = crypto.createHash('sha256').update(expected).digest();
|
|
103
|
+
return crypto.timingSafeEqual(a, b);
|
|
100
104
|
}
|
|
101
105
|
|
|
102
106
|
// --- Bootstrap ---
|
|
@@ -113,8 +117,18 @@ async function main() {
|
|
|
113
117
|
? path.resolve(process.env.GATEWAY_CONFIG_DIR)
|
|
114
118
|
: path.join(process.cwd(), 'config');
|
|
115
119
|
const configPath = path.join(configDir, 'mcp-servers.json');
|
|
116
|
-
const
|
|
117
|
-
|
|
120
|
+
const servers = readServers(configPath);
|
|
121
|
+
|
|
122
|
+
const registry = new McpRegistry({
|
|
123
|
+
onToolsLoaded: (name, tools) => {
|
|
124
|
+
for (const tool of tools) {
|
|
125
|
+
if (!tool.description) continue;
|
|
126
|
+
getOrComputeToolEmbedding(name, tool, getEmbedding).catch(err =>
|
|
127
|
+
console.error(`[gateway] Embedding error for ${name}::${tool.name}:`, err.message)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
});
|
|
118
132
|
|
|
119
133
|
const app = express();
|
|
120
134
|
if (process.env.TRUST_PROXY) {
|
|
@@ -127,10 +141,61 @@ async function main() {
|
|
|
127
141
|
// qui repartirait sinon en « 413 Payload Too Large » sans explication.
|
|
128
142
|
const BODY_LIMIT = process.env.GATEWAY_BODY_LIMIT ?? '32mb';
|
|
129
143
|
const jsonParser = express.json({ type: '*/*', limit: BODY_LIMIT });
|
|
144
|
+
app.use((req, res, next) => {
|
|
145
|
+
res.setHeader(REVISION_HEADER, registry.revision);
|
|
146
|
+
next();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Rechargement de la configuration, réservé au détenteur de GATEWAY_ADMIN_TOKEN.
|
|
150
|
+
// Seuls les MCP dont la configuration a changé sont arrêtés, lancés ou relancés.
|
|
151
|
+
const ADMIN_TOKEN = process.env.GATEWAY_ADMIN_TOKEN?.trim() || null;
|
|
152
|
+
if (ADMIN_TOKEN) {
|
|
153
|
+
app.post('/admin/reload', async (req, res) => {
|
|
154
|
+
if (!adminTokenMatches(req.get('X-Gateway-Admin-Token'), ADMIN_TOKEN)) {
|
|
155
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
156
|
+
}
|
|
157
|
+
let nextServers;
|
|
158
|
+
try {
|
|
159
|
+
nextServers = readServers(configPath);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[gateway] Reload refused, unreadable configuration:', err.message);
|
|
162
|
+
return res.status(400).json({ error: `Unreadable configuration ${configPath}: ${err.message}` });
|
|
163
|
+
}
|
|
164
|
+
const results = await registry.apply(nextServers);
|
|
165
|
+
const summary = results
|
|
166
|
+
.filter(r => r.action !== 'unchanged')
|
|
167
|
+
.map(r => `${r.name}: ${r.action}${r.ok ? '' : ' (failed)'}`);
|
|
168
|
+
console.log(`[gateway] Reload — ${summary.length ? summary.join(', ') : 'no change'}`);
|
|
169
|
+
return res.json({ revision: registry.revision, results });
|
|
170
|
+
});
|
|
171
|
+
console.log('[gateway] Admin endpoint enabled: POST /admin/reload');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Périmètre de chaque requête (voir scope.js). Sans GATEWAY_SCOPE_SECRET, tout est visible.
|
|
175
|
+
const SCOPE_SECRET = process.env.GATEWAY_SCOPE_SECRET?.trim() || null;
|
|
176
|
+
if (SCOPE_SECRET) {
|
|
177
|
+
app.use((req, res, next) => {
|
|
178
|
+
if (req.path.startsWith('/.well-known/')) {
|
|
179
|
+
req.scope = FULL_SCOPE;
|
|
180
|
+
return next();
|
|
181
|
+
}
|
|
182
|
+
const result = readScope(req.get(SCOPE_HEADER), SCOPE_SECRET);
|
|
183
|
+
if (!result.ok) return res.status(403).json({ error: result.error });
|
|
184
|
+
req.scope = result.scope;
|
|
185
|
+
res.setHeader(REVISION_HEADER, scopedRevision(registry.revision, result.scope));
|
|
186
|
+
return next();
|
|
187
|
+
});
|
|
188
|
+
console.log(`[gateway] Access scopes enforced: every request must carry a signed ${SCOPE_HEADER} header`);
|
|
189
|
+
} else {
|
|
190
|
+
app.use((req, res, next) => {
|
|
191
|
+
req.scope = FULL_SCOPE;
|
|
192
|
+
next();
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
130
196
|
// Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
|
|
131
|
-
const relayPrefixes = new Set();
|
|
132
197
|
app.use((req, res, next) =>
|
|
133
|
-
isRelayPath(relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
|
|
198
|
+
isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
|
|
134
199
|
);
|
|
135
200
|
|
|
136
201
|
const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '15', 10);
|
|
@@ -196,53 +261,8 @@ async function main() {
|
|
|
196
261
|
const dbPath = path.join(dataDir, 'embeddings', 'embeddings.db');
|
|
197
262
|
initDb(dbPath);
|
|
198
263
|
|
|
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
|
-
}
|
|
264
|
+
await registry.apply(servers);
|
|
265
|
+
app.use(mcpRoutes(registry));
|
|
246
266
|
|
|
247
267
|
app.get('/tools/search', searchLimiter, async (req, res) => {
|
|
248
268
|
const q = req.query.q?.trim();
|
|
@@ -251,12 +271,14 @@ async function main() {
|
|
|
251
271
|
try {
|
|
252
272
|
const queryEmbedding = await getEmbedding(q);
|
|
253
273
|
const results = getAllToolEmbeddings()
|
|
274
|
+
.filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
|
|
275
|
+
.filter(row => allowsTool(req.scope, row.mcp_name, row.tool_name))
|
|
254
276
|
.map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
|
|
255
277
|
.sort((a, b) => b.score - a.score)
|
|
256
278
|
.slice(0, limit)
|
|
257
279
|
.map(({ mcp_name, tool_name, description, score }) => ({
|
|
258
280
|
mcp_name,
|
|
259
|
-
mcp_description: registry.
|
|
281
|
+
mcp_description: registry.byName(mcp_name)?.description ?? null,
|
|
260
282
|
tool_name,
|
|
261
283
|
description,
|
|
262
284
|
score,
|
|
@@ -270,12 +292,14 @@ async function main() {
|
|
|
270
292
|
|
|
271
293
|
app.get('/mcp-definitions', (req, res) => {
|
|
272
294
|
res.json(
|
|
273
|
-
registry.
|
|
274
|
-
|
|
275
|
-
route,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
295
|
+
registry.entries
|
|
296
|
+
.filter(({ name }) => allowsMcp(req.scope, name))
|
|
297
|
+
.map(({ name, route, description, tools }) => ({
|
|
298
|
+
id: name,
|
|
299
|
+
route,
|
|
300
|
+
description,
|
|
301
|
+
tools: filterTools(req.scope, name, tools),
|
|
302
|
+
}))
|
|
279
303
|
);
|
|
280
304
|
});
|
|
281
305
|
|
|
@@ -283,19 +307,11 @@ async function main() {
|
|
|
283
307
|
// `tools/list`. Exposés en HTTP pour que le bridge stdio (server.js --mcp /
|
|
284
308
|
// mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
|
|
285
309
|
// 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
310
|
app.get('/startup-exposition', (req, res) => {
|
|
298
|
-
res.json({
|
|
311
|
+
res.json({
|
|
312
|
+
proxyTools: registry.proxyTools.filter(t => allowsTool(req.scope, t.mcpName, t.toolName)),
|
|
313
|
+
revision: scopedRevision(registry.revision, req.scope),
|
|
314
|
+
});
|
|
299
315
|
});
|
|
300
316
|
|
|
301
317
|
app.get('/mcps', (req, res) => {
|
|
@@ -307,7 +323,7 @@ async function main() {
|
|
|
307
323
|
'',
|
|
308
324
|
'## Serveurs',
|
|
309
325
|
'',
|
|
310
|
-
...registry.map(({ name, route, description, relayPort }) =>
|
|
326
|
+
...registry.entries.filter(({ name }) => allowsMcp(req.scope, name)).map(({ name, route, description, relayPort }) =>
|
|
311
327
|
`### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
|
|
312
328
|
(relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
|
|
313
329
|
),
|
|
@@ -315,7 +331,7 @@ async function main() {
|
|
|
315
331
|
res.type('text/plain').send(lines.join('\n'));
|
|
316
332
|
});
|
|
317
333
|
|
|
318
|
-
registerMcpEndpoint(app,
|
|
334
|
+
registerMcpEndpoint(app, registry);
|
|
319
335
|
|
|
320
336
|
const httpServer = app.listen(GATEWAY_PORT, GATEWAY_HOST, () => {
|
|
321
337
|
console.log(`[gateway] Listening on http://${GATEWAY_HOST}:${GATEWAY_PORT}`);
|
|
@@ -328,7 +344,7 @@ async function main() {
|
|
|
328
344
|
|
|
329
345
|
const shutdown = async () => {
|
|
330
346
|
console.log('[gateway] Shutting down...');
|
|
331
|
-
await
|
|
347
|
+
await registry.closeAll();
|
|
332
348
|
process.exit(0);
|
|
333
349
|
};
|
|
334
350
|
|
package/startup-exposition.js
CHANGED
|
@@ -56,8 +56,8 @@ function sanitizeSegment(value) {
|
|
|
56
56
|
return String(value).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// Construit les outils proxy à partir du registry. Chaque entrée porte `route`
|
|
60
|
-
// `toolName` pour le routage — à retirer avant publication via toolDefinition().
|
|
59
|
+
// Construit les outils proxy à partir du registry. Chaque entrée porte `route`, `mcpName` et
|
|
60
|
+
// `toolName` pour le routage et le périmètre — à retirer avant publication via toolDefinition().
|
|
61
61
|
export function buildProxyTools(registry) {
|
|
62
62
|
const proxyTools = [];
|
|
63
63
|
|
|
@@ -70,6 +70,7 @@ export function buildProxyTools(registry) {
|
|
|
70
70
|
description: tool.description ?? '',
|
|
71
71
|
inputSchema: tool.inputSchema ?? { type: 'object', properties: {} },
|
|
72
72
|
route: entry.route,
|
|
73
|
+
mcpName: entry.name,
|
|
73
74
|
toolName: tool.name,
|
|
74
75
|
});
|
|
75
76
|
}
|