@imenam/mcp-http-gateway 2.0.16 → 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 +35 -0
- package/mcp-endpoint.js +22 -7
- package/package.json +3 -2
- package/scope.js +123 -0
- package/server.js +47 -10
- package/startup-exposition.js +3 -2
package/README.md
CHANGED
|
@@ -53,6 +53,7 @@ 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). |
|
|
57
58
|
|
|
58
59
|
### `mcp-servers.json`
|
|
@@ -147,6 +148,40 @@ Le jeton passe par l'en-tête `X-Gateway-Admin-Token`, indépendamment de l'auth
|
|
|
147
148
|
|
|
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.
|
|
149
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
|
+
|
|
150
185
|
## Routes
|
|
151
186
|
|
|
152
187
|
### `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.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",
|
|
@@ -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,6 +171,28 @@ 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)
|
|
@@ -241,6 +272,7 @@ async function main() {
|
|
|
241
272
|
const queryEmbedding = await getEmbedding(q);
|
|
242
273
|
const results = getAllToolEmbeddings()
|
|
243
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))
|
|
244
276
|
.map(row => ({ ...row, score: cosineSimilarity(queryEmbedding, row.embedding) }))
|
|
245
277
|
.sort((a, b) => b.score - a.score)
|
|
246
278
|
.slice(0, limit)
|
|
@@ -260,12 +292,14 @@ async function main() {
|
|
|
260
292
|
|
|
261
293
|
app.get('/mcp-definitions', (req, res) => {
|
|
262
294
|
res.json(
|
|
263
|
-
registry.entries
|
|
264
|
-
|
|
265
|
-
route,
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
}))
|
|
269
303
|
);
|
|
270
304
|
});
|
|
271
305
|
|
|
@@ -274,7 +308,10 @@ async function main() {
|
|
|
274
308
|
// mcp-mode.js) puisse les récupérer et les relayer à l'agent de façon transparente —
|
|
275
309
|
// la config vit uniquement côté serveur, le bridge se contente de transmettre.
|
|
276
310
|
app.get('/startup-exposition', (req, res) => {
|
|
277
|
-
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
|
+
});
|
|
278
315
|
});
|
|
279
316
|
|
|
280
317
|
app.get('/mcps', (req, res) => {
|
|
@@ -286,7 +323,7 @@ async function main() {
|
|
|
286
323
|
'',
|
|
287
324
|
'## Serveurs',
|
|
288
325
|
'',
|
|
289
|
-
...registry.entries.map(({ name, route, description, relayPort }) =>
|
|
326
|
+
...registry.entries.filter(({ name }) => allowsMcp(req.scope, name)).map(({ name, route, description, relayPort }) =>
|
|
290
327
|
`### ${name}\n- **Route** : \`${route}\`\n- **Description** : ${description ?? 'Non renseignée'}` +
|
|
291
328
|
(relayPort ? `\n- **Relais HTTP** : \`${relayPrefix(route)}/*\`` : '')
|
|
292
329
|
),
|
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
|
}
|