@imenam/mcp-http-gateway 2.0.16 → 2.0.20
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 +39 -0
- package/mcp-endpoint.js +22 -7
- package/package.json +3 -2
- package/scope.js +123 -0
- package/server.js +67 -25
- package/startup-exposition.js +3 -2
package/README.md
CHANGED
|
@@ -53,7 +53,12 @@ GATEWAY_CONFIG_DIR=./config
|
|
|
53
53
|
| `GATEWAY_PORT` | `3500` | Port HTTP du gateway |
|
|
54
54
|
| `GATEWAY_CONFIG_DIR` | `./config` (relatif au cwd) | Dossier contenant `mcp-servers.json` |
|
|
55
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). |
|
|
56
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). |
|
|
58
|
+
| `RATE_LIMIT_GLOBAL_MAX` / `RATE_LIMIT_GLOBAL_WINDOW_MINUTES` | `1000` / `1` | Requêtes acceptées par IP sur la fenêtre, toutes routes confondues ; au-delà, la réponse est un `429`. `0` désactive la limite. |
|
|
59
|
+
| `RATE_LIMIT_SEARCH_MAX` / `RATE_LIMIT_SEARCH_WINDOW_MINUTES` | `30` / `1` | Même principe pour `/tools/search`, en plus de la limite globale. `0` désactive la limite. |
|
|
60
|
+
|
|
61
|
+
Derrière un frontal qui authentifie déjà les appelants et borne leur fréquence, comme mcp-gateway-manager, les deux limites se mettent à `0`.
|
|
57
62
|
|
|
58
63
|
### `mcp-servers.json`
|
|
59
64
|
|
|
@@ -147,6 +152,40 @@ Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'auth
|
|
|
147
152
|
|
|
148
153
|
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
154
|
|
|
155
|
+
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.
|
|
156
|
+
|
|
157
|
+
## Périmètre d'accès
|
|
158
|
+
|
|
159
|
+
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.
|
|
160
|
+
|
|
161
|
+
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.
|
|
162
|
+
|
|
163
|
+
L'en-tête vaut `<charge>.<signature>` :
|
|
164
|
+
|
|
165
|
+
- `charge` : un objet JSON, encodé en base64url ;
|
|
166
|
+
- `signature` : le HMAC-SHA256 de la charge encodée, avec `GATEWAY_SCOPE_SECRET` pour clé, en base64url.
|
|
167
|
+
|
|
168
|
+
```jsonc
|
|
169
|
+
{ "v": 1, "all": true } // tout le gateway
|
|
170
|
+
{ "v": 1, "mcps": {
|
|
171
|
+
"documentor": "*", // tous les outils de ce MCP, y compris ceux ajoutés plus tard
|
|
172
|
+
"github": ["get_info", "pull_request"] // ces outils seulement
|
|
173
|
+
} }
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Les MCP sont désignés par leur nom (la clé de `mcpServers`). Le périmètre s'applique partout :
|
|
177
|
+
|
|
178
|
+
| Point d'accès | MCP hors périmètre | Outil hors périmètre |
|
|
179
|
+
|---|---|---|
|
|
180
|
+
| `GET /mcps`, `GET /mcp-definitions`, `list_mcps` | absent | absent de `tools` |
|
|
181
|
+
| `GET /{route}/tools`, `list_tools` | `404` / « No MCP found » | absent |
|
|
182
|
+
| `POST /{route}/tools/:toolName`, `call_tool` | `404` / « No MCP found » | `403` / « Tool not allowed » |
|
|
183
|
+
| `/{route}/http/*` | `404` | — (le relais suit le MCP) |
|
|
184
|
+
| `GET /startup-exposition`, `tools/list` de `/mcp` | outils absents | outil absent |
|
|
185
|
+
| `GET /tools/search` | résultats absents | résultat absent |
|
|
186
|
+
|
|
187
|
+
Le module `scope.js` exporte `encodeScope(charge, secret)`, qui fabrique l'en-tête.
|
|
188
|
+
|
|
150
189
|
## Routes
|
|
151
190
|
|
|
152
191
|
### `GET /mcps`
|
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,17 +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.
|
|
49
|
-
const
|
|
54
|
+
const proxyTools = registry.proxyTools.filter(t => allowsTool(scope, t.mcpName, t.toolName));
|
|
50
55
|
const proxyIndex = new Map(proxyTools.map(t => [t.name, t]));
|
|
51
56
|
const allTools = [...tools, ...proxyTools.map(toolDefinition)];
|
|
52
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
|
+
|
|
53
64
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: allTools }));
|
|
54
65
|
|
|
55
66
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
@@ -58,7 +69,7 @@ function buildMcpServer(registry) {
|
|
|
58
69
|
try {
|
|
59
70
|
const proxy = proxyIndex.get(name);
|
|
60
71
|
if (proxy) {
|
|
61
|
-
const entry =
|
|
72
|
+
const entry = visibleEntry(proxy.route);
|
|
62
73
|
if (!entry) {
|
|
63
74
|
return { content: [{ type: 'text', text: `No MCP found for route: ${proxy.route}` }], isError: true };
|
|
64
75
|
}
|
|
@@ -67,25 +78,29 @@ function buildMcpServer(registry) {
|
|
|
67
78
|
|
|
68
79
|
if (name === 'list_mcps') {
|
|
69
80
|
const text = registry.entries
|
|
81
|
+
.filter(m => allowsMcp(scope, m.name))
|
|
70
82
|
.map(m => `- ${m.name} (route: ${m.route})${m.description ? ` — ${m.description}` : ''}`)
|
|
71
83
|
.join('\n');
|
|
72
84
|
return { content: [{ type: 'text', text }] };
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
if (name === 'list_tools') {
|
|
76
|
-
const entry =
|
|
88
|
+
const entry = visibleEntry(args.route);
|
|
77
89
|
if (!entry) {
|
|
78
90
|
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
79
91
|
}
|
|
80
|
-
const text = JSON.stringify(entry.tools);
|
|
92
|
+
const text = JSON.stringify(filterTools(scope, entry.name, entry.tools));
|
|
81
93
|
return { content: [{ type: 'text', text }] };
|
|
82
94
|
}
|
|
83
95
|
|
|
84
96
|
if (name === 'call_tool') {
|
|
85
|
-
const entry =
|
|
97
|
+
const entry = visibleEntry(args.route);
|
|
86
98
|
if (!entry) {
|
|
87
99
|
return { content: [{ type: 'text', text: `No MCP found for route: ${args.route}` }], isError: true };
|
|
88
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
|
+
}
|
|
89
104
|
const result = await entry.client.callTool({
|
|
90
105
|
name: args.tool_name,
|
|
91
106
|
arguments: args.arguments ?? {},
|
|
@@ -139,7 +154,7 @@ export function registerMcpEndpoint(app, registry) {
|
|
|
139
154
|
// Conséquence : plus de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
|
|
140
155
|
// et l'endpoint supporte le scale-out multi-instances.
|
|
141
156
|
app.post('/mcp', async (req, res) => {
|
|
142
|
-
const server = buildMcpServer(registry);
|
|
157
|
+
const server = buildMcpServer(registry, req.scope);
|
|
143
158
|
const transport = new StreamableHTTPServerTransport({
|
|
144
159
|
sessionIdGenerator: undefined,
|
|
145
160
|
enableJsonResponse: true,
|
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.20",
|
|
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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"http-relay.js",
|
|
19
19
|
"gateway-exec.js",
|
|
20
20
|
"gateway-watch.js",
|
|
21
|
-
"mcp-registry.js"
|
|
21
|
+
"mcp-registry.js",
|
|
22
|
+
"scope.js"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
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
|
@@ -23,6 +23,9 @@ import { registerMcpEndpoint } from './mcp-endpoint.js';
|
|
|
23
23
|
import { createMcpTokenVerifier } from './auth.js';
|
|
24
24
|
import { relayPrefix, isRelayPath, createHttpRelay } from './http-relay.js';
|
|
25
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';
|
|
26
29
|
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
27
30
|
import { rateLimit } from 'express-rate-limit';
|
|
28
31
|
|
|
@@ -41,13 +44,16 @@ function normalizeCfg(cfg) {
|
|
|
41
44
|
|
|
42
45
|
/**
|
|
43
46
|
* Routes `<route>/tools`, `<route>/tools/:toolName` et `<route>/http/*` de chaque MCP,
|
|
44
|
-
* résolues sur le registre à chaque requête
|
|
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.
|
|
45
50
|
*/
|
|
46
51
|
function mcpRoutes(registry) {
|
|
47
52
|
return async (req, res, next) => {
|
|
48
53
|
for (const entry of registry.entries) {
|
|
49
54
|
const { route, name, client } = entry;
|
|
50
55
|
if (req.path !== route && !req.path.startsWith(`${route}/`)) continue;
|
|
56
|
+
if (!allowsMcp(req.scope, name)) return next();
|
|
51
57
|
const rest = req.path.slice(route.length);
|
|
52
58
|
|
|
53
59
|
if (entry.relayPort && (rest === '/http' || rest.startsWith('/http/'))) {
|
|
@@ -58,7 +64,7 @@ function mcpRoutes(registry) {
|
|
|
58
64
|
if (req.method === 'GET' && rest === '/tools') {
|
|
59
65
|
try {
|
|
60
66
|
const { tools } = await client.listTools();
|
|
61
|
-
return res.json(tools);
|
|
67
|
+
return res.json(filterTools(req.scope, name, tools));
|
|
62
68
|
} catch (err) {
|
|
63
69
|
console.error(`[${name}] listTools error:`, err.message);
|
|
64
70
|
return res.status(500).json({ error: err.message });
|
|
@@ -68,6 +74,9 @@ function mcpRoutes(registry) {
|
|
|
68
74
|
const toolMatch = /^\/tools\/([^/]+)$/.exec(rest);
|
|
69
75
|
if (req.method === 'POST' && toolMatch) {
|
|
70
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
|
+
}
|
|
71
80
|
try {
|
|
72
81
|
const result = await client.callTool({ name: toolName, arguments: req.body ?? {} });
|
|
73
82
|
return res.json(result);
|
|
@@ -162,33 +171,60 @@ async function main() {
|
|
|
162
171
|
console.log('[gateway] Admin endpoint enabled: POST /admin/reload');
|
|
163
172
|
}
|
|
164
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
|
+
|
|
165
196
|
// Les routes de relais HTTP transmettent leur corps en flux, sans le lire ni le borner.
|
|
166
197
|
app.use((req, res, next) =>
|
|
167
198
|
isRelayPath(registry.relayPrefixes, req.path) ? next() : jsonParser(req, res, next)
|
|
168
199
|
);
|
|
169
200
|
|
|
170
|
-
|
|
171
|
-
const
|
|
201
|
+
// Une limite à 0 désactive le compteur correspondant.
|
|
202
|
+
const globalWindowMin = parseInt(process.env.RATE_LIMIT_GLOBAL_WINDOW_MINUTES ?? '1', 10);
|
|
203
|
+
const globalMax = parseInt(process.env.RATE_LIMIT_GLOBAL_MAX ?? '1000', 10);
|
|
172
204
|
const searchWindowMin = parseInt(process.env.RATE_LIMIT_SEARCH_WINDOW_MINUTES ?? '1', 10);
|
|
173
205
|
const searchMax = parseInt(process.env.RATE_LIMIT_SEARCH_MAX ?? '30', 10);
|
|
174
206
|
|
|
175
|
-
|
|
176
|
-
windowMs:
|
|
177
|
-
|
|
178
|
-
standardHeaders: 'draft-8',
|
|
179
|
-
legacyHeaders: false,
|
|
180
|
-
message: { error: 'Too Many Requests' },
|
|
181
|
-
}));
|
|
182
|
-
console.log(`[gateway] Rate limit — global: ${globalMax} req/${globalWindowMin} min/IP`);
|
|
183
|
-
|
|
184
|
-
const searchLimiter = rateLimit({
|
|
185
|
-
windowMs: searchWindowMin * 60_000,
|
|
186
|
-
max: searchMax,
|
|
207
|
+
const limiter = (windowMin, max) => rateLimit({
|
|
208
|
+
windowMs: windowMin * 60_000,
|
|
209
|
+
limit: max,
|
|
187
210
|
standardHeaders: 'draft-8',
|
|
188
211
|
legacyHeaders: false,
|
|
189
212
|
message: { error: 'Too Many Requests' },
|
|
190
213
|
});
|
|
191
|
-
|
|
214
|
+
|
|
215
|
+
if (globalMax > 0) {
|
|
216
|
+
app.use(limiter(globalWindowMin, globalMax));
|
|
217
|
+
console.log(`[gateway] Rate limit — global: ${globalMax} req/${globalWindowMin} min/IP`);
|
|
218
|
+
} else {
|
|
219
|
+
console.log('[gateway] Rate limit — global: disabled');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const searchLimiter = searchMax > 0
|
|
223
|
+
? limiter(searchWindowMin, searchMax)
|
|
224
|
+
: (req, res, next) => next();
|
|
225
|
+
console.log(searchMax > 0
|
|
226
|
+
? `[gateway] Rate limit — /tools/search: ${searchMax} req/${searchWindowMin} min/IP`
|
|
227
|
+
: '[gateway] Rate limit — /tools/search: disabled');
|
|
192
228
|
|
|
193
229
|
const verifier = createMcpTokenVerifier();
|
|
194
230
|
if (verifier) {
|
|
@@ -241,6 +277,7 @@ async function main() {
|
|
|
241
277
|
const queryEmbedding = await getEmbedding(q);
|
|
242
278
|
const results = getAllToolEmbeddings()
|
|
243
279
|
.filter(row => registry.byName(row.mcp_name)?.tools.some(t => t.name === row.tool_name))
|
|
280
|
+
.filter(row => allowsTool(req.scope, row.mcp_name, row.tool_name))
|
|
244
281
|
.map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
|
|
245
282
|
.sort((a, b) => b.score - a.score)
|
|
246
283
|
.slice(0, limit)
|
|
@@ -260,12 +297,14 @@ async function main() {
|
|
|
260
297
|
|
|
261
298
|
app.get('/mcp-definitions', (req, res) => {
|
|
262
299
|
res.json(
|
|
263
|
-
registry.entries
|
|
264
|
-
|
|
265
|
-
route,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
300
|
+
registry.entries
|
|
301
|
+
.filter(({ name }) => allowsMcp(req.scope, name))
|
|
302
|
+
.map(({ name, route, description, tools }) => ({
|
|
303
|
+
id: name,
|
|
304
|
+
route,
|
|
305
|
+
description,
|
|
306
|
+
tools: filterTools(req.scope, name, tools),
|
|
307
|
+
}))
|
|
269
308
|
);
|
|
270
309
|
});
|
|
271
310
|
|
|
@@ -274,7 +313,10 @@ async function main() {
|
|
|
274
313
|
// mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
|
|
275
314
|
// la config vit uniquement côté serveur, le bridge se contente de transmettre.
|
|
276
315
|
app.get('/startup-exposition', (req, res) => {
|
|
277
|
-
res.json({
|
|
316
|
+
res.json({
|
|
317
|
+
proxyTools: registry.proxyTools.filter(t => allowsTool(req.scope, t.mcpName, t.toolName)),
|
|
318
|
+
revision: scopedRevision(registry.revision, req.scope),
|
|
319
|
+
});
|
|
278
320
|
});
|
|
279
321
|
|
|
280
322
|
app.get('/mcps', (req, res) => {
|
|
@@ -286,7 +328,7 @@ async function main() {
|
|
|
286
328
|
'',
|
|
287
329
|
'## Serveurs',
|
|
288
330
|
'',
|
|
289
|
-
...registry.entries.map(({ name, route, description, relayPort }) =>
|
|
331
|
+
...registry.entries.filter(({ name }) => allowsMcp(req.scope, name)).map(({ name, route, description, relayPort }) =>
|
|
290
332
|
`### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
|
|
291
333
|
(relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
|
|
292
334
|
),
|
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
|
}
|