@imenam/mcp-http-gateway 2.0.22 → 2.0.24
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 +17 -0
- package/auth.js +3 -2
- package/embeddings/db.js +58 -58
- package/embeddings/vectorizer.js +25 -25
- package/mcp-endpoint.js +53 -42
- package/mcp-mode.js +4 -8
- package/mcp-registry.js +250 -250
- package/package.json +62 -59
- package/server.js +1 -1
package/README.md
CHANGED
|
@@ -612,6 +612,15 @@ Chaque `POST /mcp` est traité de façon autonome : aucun `mcp-session-id` n'est
|
|
|
612
612
|
|
|
613
613
|
`GET /mcp` et `DELETE /mcp` répondent `405 Method Not Allowed` : sans session, il n'y a ni flux SSE autonome ni terminaison de session à gérer (la spec Streamable HTTP l'autorise explicitement).
|
|
614
614
|
|
|
615
|
+
L'endpoint sert deux générations du protocole, toujours en réponse JSON :
|
|
616
|
+
|
|
617
|
+
- **2026-07-28** : le client se présente par `server/discover`, sans `initialize` ;
|
|
618
|
+
- **2025-11-25 et antérieures** : poignée de main par `initialize`.
|
|
619
|
+
|
|
620
|
+
La génération est reconnue requête par requête : un client 2026-07-28 se connecte directement, sans essuyer de refus.
|
|
621
|
+
|
|
622
|
+
Les requêtes dont l'en-tête `Host` ou `Origin` n'est pas autorisé sont refusées en `403` (protection anti-DNS-rebinding). Sont autorisés les hôtes `localhost`, celui de `MCP_PUBLIC_URL` et ceux de `GATEWAY_ALLOWED_HOSTS`, ainsi que les origines `https://claude.ai` et celles de `GATEWAY_ALLOWED_ORIGINS`. La comparaison porte sur le nom d'hôte seul, sans le port. Une requête sans en-tête `Origin` est acceptée.
|
|
623
|
+
|
|
615
624
|
### Flow
|
|
616
625
|
|
|
617
626
|
```
|
|
@@ -635,6 +644,8 @@ Le gateway agit uniquement comme **Resource Server** : il valide les JWT via JWK
|
|
|
635
644
|
| `MCP_PUBLIC_URL` | URL publique canonique du endpoint — ex. `https://gateway.example.com/mcp`. Utilisée comme `resource` (RFC 9728) et `aud` JWT. |
|
|
636
645
|
| `OAUTH_JWKS_URI` | URI du JWKS de l'Authorization Server — ex. `https://your-auth-server/.well-known/jwks.json` |
|
|
637
646
|
| `OAUTH_ISSUER` | Issuer attendu dans les JWT — ex. `https://your-auth-server` |
|
|
647
|
+
| `GATEWAY_ALLOWED_HOSTS` | Hôtes supplémentaires admis sur `/mcp`, séparés par des virgules — ex. `mon-autre-host.example.com` |
|
|
648
|
+
| `GATEWAY_ALLOWED_ORIGINS` | Origines supplémentaires admises sur `/mcp`, séparées par des virgules — ex. `https://mon-client.example.com` |
|
|
638
649
|
|
|
639
650
|
> Sans `MCP_OAUTH_ENABLED=true`, `/mcp` est ouvert (utile pour les tests locaux). Claude.ai refusera cependant de s'y connecter sans 401 + `WWW-Authenticate`.
|
|
640
651
|
|
|
@@ -754,3 +765,9 @@ points d'entrée par lesquels un agent atteint le gateway :
|
|
|
754
765
|
2. Appeler `POST /admin/reload` (voir [Rechargement à chaud](#rechargement-à-chaud)), ou redémarrer le gateway
|
|
755
766
|
|
|
756
767
|
Aucune modification du MCP n'est nécessaire.
|
|
768
|
+
|
|
769
|
+
## Publication
|
|
770
|
+
|
|
771
|
+
Chaque push sur `master`, merge de pull request compris, publie une nouvelle version de `@imenam/mcp-http-gateway` sur npm (`.github/workflows/publish.yml`) : `npm ci`, puis `npm version patch`, dont le commit et le tag sont poussés sur `master`, et `npm publish`. La publication passe par le trusted publishing de npm (OIDC, sans token), avec attestation de provenance.
|
|
772
|
+
|
|
773
|
+
Sur npmjs.com, le paquet déclare ce workflow comme publieur de confiance (Settings → Trusted publishing → GitHub Actions : `Matthieu-Pesnot-Pin`, `mcp-http-gateway`, `publish.yml`, avec `npm publish` dans les actions autorisées). Le commit de version est poussé avec `GITHUB_TOKEN` : si `master` est protégée, la règle doit autoriser GitHub Actions à y pousser.
|
package/auth.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
-
import {
|
|
2
|
+
import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
|
3
3
|
|
|
4
4
|
const OAUTH_ENABLED = process.env.MCP_OAUTH_ENABLED === 'true';
|
|
5
5
|
|
|
@@ -22,7 +22,8 @@ export function createMcpTokenVerifier() {
|
|
|
22
22
|
} catch (err) {
|
|
23
23
|
const ts = new Date().toISOString();
|
|
24
24
|
console.error(`[auth] ${ts} jwtVerify FAILED:`, err.code ?? err.constructor.name, err.message);
|
|
25
|
-
throw new
|
|
25
|
+
throw new OAuthError(
|
|
26
|
+
OAuthErrorCode.InvalidToken,
|
|
26
27
|
err.code === 'ERR_JWT_EXPIRED' ? 'Token has expired' : 'Invalid token'
|
|
27
28
|
);
|
|
28
29
|
}
|
package/embeddings/db.js
CHANGED
|
@@ -1,58 +1,58 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
import Database from 'better-sqlite3';
|
|
5
|
-
|
|
6
|
-
let database;
|
|
7
|
-
|
|
8
|
-
export function initDb(dbPath) {
|
|
9
|
-
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
10
|
-
database = new Database(dbPath);
|
|
11
|
-
database.pragma('journal_mode = WAL');
|
|
12
|
-
database.exec(`
|
|
13
|
-
CREATE TABLE IF NOT EXISTS tool_embeddings (
|
|
14
|
-
cache_key TEXT PRIMARY KEY,
|
|
15
|
-
desc_hash TEXT NOT NULL,
|
|
16
|
-
mcp_name TEXT NOT NULL,
|
|
17
|
-
tool_name TEXT NOT NULL,
|
|
18
|
-
description TEXT NOT NULL,
|
|
19
|
-
embedding BLOB NOT NULL
|
|
20
|
-
);
|
|
21
|
-
`);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export async function getOrComputeToolEmbedding(mcpName, tool, getEmbeddingFn) {
|
|
25
|
-
const cacheKey = `${mcpName}::${tool.name}`;
|
|
26
|
-
const descHash = crypto.createHash('md5').update(tool.description).digest('hex');
|
|
27
|
-
|
|
28
|
-
const existing = database
|
|
29
|
-
.prepare('SELECT desc_hash FROM tool_embeddings WHERE cache_key = ?')
|
|
30
|
-
.get(cacheKey);
|
|
31
|
-
|
|
32
|
-
if (existing?.desc_hash === descHash) return;
|
|
33
|
-
|
|
34
|
-
const embedding = await getEmbeddingFn(tool.description);
|
|
35
|
-
database.prepare(`
|
|
36
|
-
INSERT INTO tool_embeddings (cache_key, desc_hash, mcp_name, tool_name, description, embedding)
|
|
37
|
-
VALUES (?, ?, ?, ?, ?, ?)
|
|
38
|
-
ON CONFLICT(cache_key) DO UPDATE SET
|
|
39
|
-
desc_hash = excluded.desc_hash,
|
|
40
|
-
description = excluded.description,
|
|
41
|
-
embedding = excluded.embedding
|
|
42
|
-
`).run(
|
|
43
|
-
cacheKey, descHash, mcpName, tool.name, tool.description,
|
|
44
|
-
Buffer.from(new Float32Array(embedding).buffer),
|
|
45
|
-
);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function getAllToolEmbeddings() {
|
|
49
|
-
return database
|
|
50
|
-
.prepare('SELECT mcp_name, tool_name, description, embedding FROM tool_embeddings')
|
|
51
|
-
.all()
|
|
52
|
-
.map(row => ({
|
|
53
|
-
mcp_name: row.mcp_name,
|
|
54
|
-
tool_name: row.tool_name,
|
|
55
|
-
description: row.description,
|
|
56
|
-
embedding: Array.from(new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4)),
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
|
|
6
|
+
let database;
|
|
7
|
+
|
|
8
|
+
export function initDb(dbPath) {
|
|
9
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
10
|
+
database = new Database(dbPath);
|
|
11
|
+
database.pragma('journal_mode = WAL');
|
|
12
|
+
database.exec(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS tool_embeddings (
|
|
14
|
+
cache_key TEXT PRIMARY KEY,
|
|
15
|
+
desc_hash TEXT NOT NULL,
|
|
16
|
+
mcp_name TEXT NOT NULL,
|
|
17
|
+
tool_name TEXT NOT NULL,
|
|
18
|
+
description TEXT NOT NULL,
|
|
19
|
+
embedding BLOB NOT NULL
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function getOrComputeToolEmbedding(mcpName, tool, getEmbeddingFn) {
|
|
25
|
+
const cacheKey = `${mcpName}::${tool.name}`;
|
|
26
|
+
const descHash = crypto.createHash('md5').update(tool.description).digest('hex');
|
|
27
|
+
|
|
28
|
+
const existing = database
|
|
29
|
+
.prepare('SELECT desc_hash FROM tool_embeddings WHERE cache_key = ?')
|
|
30
|
+
.get(cacheKey);
|
|
31
|
+
|
|
32
|
+
if (existing?.desc_hash === descHash) return;
|
|
33
|
+
|
|
34
|
+
const embedding = await getEmbeddingFn(tool.description);
|
|
35
|
+
database.prepare(`
|
|
36
|
+
INSERT INTO tool_embeddings (cache_key, desc_hash, mcp_name, tool_name, description, embedding)
|
|
37
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
38
|
+
ON CONFLICT(cache_key) DO UPDATE SET
|
|
39
|
+
desc_hash = excluded.desc_hash,
|
|
40
|
+
description = excluded.description,
|
|
41
|
+
embedding = excluded.embedding
|
|
42
|
+
`).run(
|
|
43
|
+
cacheKey, descHash, mcpName, tool.name, tool.description,
|
|
44
|
+
Buffer.from(new Float32Array(embedding).buffer),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getAllToolEmbeddings() {
|
|
49
|
+
return database
|
|
50
|
+
.prepare('SELECT mcp_name, tool_name, description, embedding FROM tool_embeddings')
|
|
51
|
+
.all()
|
|
52
|
+
.map(row => ({
|
|
53
|
+
mcp_name: row.mcp_name,
|
|
54
|
+
tool_name: row.tool_name,
|
|
55
|
+
description: row.description,
|
|
56
|
+
embedding: Array.from(new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.embedding.byteLength / 4)),
|
|
57
|
+
}));
|
|
58
|
+
}
|
package/embeddings/vectorizer.js
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
import OpenAI from 'openai';
|
|
2
|
-
|
|
3
|
-
let openaiClient;
|
|
4
|
-
|
|
5
|
-
function getClient() {
|
|
6
|
-
if (!openaiClient) openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
7
|
-
return openaiClient;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export async function getEmbedding(text) {
|
|
11
|
-
const model = process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small';
|
|
12
|
-
const response = await getClient().embeddings.create({ model, input: text });
|
|
13
|
-
return response.data[0].embedding;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function cosineSimilarity(vecA, vecB) {
|
|
17
|
-
let dot = 0, normA = 0, normB = 0;
|
|
18
|
-
for (let i = 0; i < vecA.length; i++) {
|
|
19
|
-
dot += vecA[i] * vecB[i];
|
|
20
|
-
normA += vecA[i] ** 2;
|
|
21
|
-
normB += vecB[i] ** 2;
|
|
22
|
-
}
|
|
23
|
-
const mag = Math.sqrt(normA) * Math.sqrt(normB);
|
|
24
|
-
return mag === 0 ? 0 : dot / mag;
|
|
25
|
-
}
|
|
1
|
+
import OpenAI from 'openai';
|
|
2
|
+
|
|
3
|
+
let openaiClient;
|
|
4
|
+
|
|
5
|
+
function getClient() {
|
|
6
|
+
if (!openaiClient) openaiClient = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
7
|
+
return openaiClient;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function getEmbedding(text) {
|
|
11
|
+
const model = process.env.OPENAI_EMBEDDING_MODEL ?? 'text-embedding-3-small';
|
|
12
|
+
const response = await getClient().embeddings.create({ model, input: text });
|
|
13
|
+
return response.data[0].embedding;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cosineSimilarity(vecA, vecB) {
|
|
17
|
+
let dot = 0, normA = 0, normB = 0;
|
|
18
|
+
for (let i = 0; i < vecA.length; i++) {
|
|
19
|
+
dot += vecA[i] * vecB[i];
|
|
20
|
+
normA += vecA[i] ** 2;
|
|
21
|
+
normB += vecB[i] ** 2;
|
|
22
|
+
}
|
|
23
|
+
const mag = Math.sqrt(normA) * Math.sqrt(normB);
|
|
24
|
+
return mag === 0 ? 0 : dot / mag;
|
|
25
|
+
}
|
package/mcp-endpoint.js
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import { Server } from '@modelcontextprotocol/
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
ListToolsRequestSchema,
|
|
5
|
-
CallToolRequestSchema,
|
|
6
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
1
|
+
import { Server, createMcpHandler, isLegacyRequest } from '@modelcontextprotocol/server';
|
|
2
|
+
import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node';
|
|
3
|
+
import { hostHeaderValidation, originValidation } from '@modelcontextprotocol/express';
|
|
7
4
|
import { getProtectedResourceMetadata } from './auth.js';
|
|
8
5
|
import { toolDefinition } from './startup-exposition.js';
|
|
9
6
|
import { allowsMcp, allowsTool, filterTools } from './scope.js';
|
|
@@ -61,9 +58,9 @@ function buildMcpServer(registry, scope) {
|
|
|
61
58
|
return entry && allowsMcp(scope, entry.name) ? entry : null;
|
|
62
59
|
};
|
|
63
60
|
|
|
64
|
-
server.setRequestHandler(
|
|
61
|
+
server.setRequestHandler('tools/list', async () => ({ tools: allTools }));
|
|
65
62
|
|
|
66
|
-
server.setRequestHandler(
|
|
63
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
67
64
|
const { name, arguments: args = {} } = request.params;
|
|
68
65
|
|
|
69
66
|
try {
|
|
@@ -117,24 +114,29 @@ function buildMcpServer(registry, scope) {
|
|
|
117
114
|
return server;
|
|
118
115
|
}
|
|
119
116
|
|
|
117
|
+
/** Nom d'hôte, sans port, d'un hôte (`exemple.com:8443`) ou d'une origine (`https://exemple.com`). */
|
|
118
|
+
function hostnameOf(value) {
|
|
119
|
+
try {
|
|
120
|
+
return new URL(value.includes('://') ? value : `http://${value}`).hostname;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const envList = (name) => (process.env[name] ?? '').split(',').map(s => s.trim()).filter(Boolean);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Noms d'hôte acceptés pour la protection DNS rebinding : la vérification du SDK ignore le port.
|
|
130
|
+
*/
|
|
120
131
|
function buildDnsRebindingConfig() {
|
|
121
|
-
const port = process.env.GATEWAY_PORT ?? '3500';
|
|
122
|
-
const extraHosts = process.env.GATEWAY_ALLOWED_HOSTS
|
|
123
|
-
? process.env.GATEWAY_ALLOWED_HOSTS.split(',').map(s => s.trim()).filter(Boolean)
|
|
124
|
-
: [];
|
|
125
|
-
const publicHost = process.env.MCP_PUBLIC_URL
|
|
126
|
-
? (() => { try { return new URL(process.env.MCP_PUBLIC_URL).host; } catch { return null; } })()
|
|
127
|
-
: null;
|
|
128
132
|
const allowedHosts = [
|
|
129
|
-
|
|
130
|
-
...(
|
|
131
|
-
...
|
|
132
|
-
];
|
|
133
|
+
'localhost',
|
|
134
|
+
...(process.env.MCP_PUBLIC_URL ? [process.env.MCP_PUBLIC_URL] : []),
|
|
135
|
+
...envList('GATEWAY_ALLOWED_HOSTS'),
|
|
136
|
+
].map(hostnameOf).filter(Boolean);
|
|
133
137
|
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
: [];
|
|
137
|
-
const allowedOrigins = ['https://claude.ai', ...extraOrigins];
|
|
138
|
+
const allowedOrigins = ['https://claude.ai', ...envList('GATEWAY_ALLOWED_ORIGINS')]
|
|
139
|
+
.map(hostnameOf).filter(Boolean);
|
|
138
140
|
|
|
139
141
|
return { allowedHosts, allowedOrigins };
|
|
140
142
|
}
|
|
@@ -148,31 +150,40 @@ export function registerMcpEndpoint(app, registry) {
|
|
|
148
150
|
console.log('[gateway] DNS rebinding protection — allowedHosts:', allowedHosts);
|
|
149
151
|
console.log('[gateway] DNS rebinding protection — allowedOrigins:', allowedOrigins);
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
const logError = (err) => console.error('[mcp-endpoint] handleMcpRequest error:', err);
|
|
154
|
+
|
|
155
|
+
// Mode stateless : un Server éphémère par requête, aucun état inter-requêtes.
|
|
152
156
|
// Les trois outils exposés sont du pur requête/réponse (aucune notification serveur→client,
|
|
153
157
|
// ni sampling, ni subscription), donc aucune session n'est nécessaire.
|
|
154
|
-
// Conséquence :
|
|
158
|
+
// Conséquence : pas de 404 "session inconnue" après une période d'inactivité ou un redémarrage,
|
|
155
159
|
// et l'endpoint supporte le scale-out multi-instances.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
enableDnsRebindingProtection: true,
|
|
162
|
-
allowedHosts,
|
|
163
|
-
allowedOrigins,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
res.on('close', () => {
|
|
167
|
-
transport.close().catch(() => {});
|
|
168
|
-
server.close().catch(() => {});
|
|
169
|
-
});
|
|
160
|
+
// Deux générations du protocole sur le même endpoint, toutes deux en réponse JSON :
|
|
161
|
+
// - révisions 2025 (qui commencent par `initialize`) : transport Streamable HTTP sans session ;
|
|
162
|
+
// - révision 2026-07-28 (dont `server/discover`) : handler par requête du SDK.
|
|
163
|
+
app.post('/mcp', hostHeaderValidation(allowedHosts), originValidation(allowedOrigins), async (req, res) => {
|
|
164
|
+
const factory = () => buildMcpServer(registry, req.scope);
|
|
170
165
|
|
|
171
166
|
try {
|
|
172
|
-
await
|
|
173
|
-
|
|
167
|
+
if (await isLegacyRequest(await toWebRequest(req, req.body), req.body)) {
|
|
168
|
+
const server = factory();
|
|
169
|
+
const transport = new NodeStreamableHTTPServerTransport({
|
|
170
|
+
sessionIdGenerator: undefined,
|
|
171
|
+
enableJsonResponse: true,
|
|
172
|
+
});
|
|
173
|
+
res.on('close', () => {
|
|
174
|
+
transport.close().catch(() => {});
|
|
175
|
+
server.close().catch(() => {});
|
|
176
|
+
});
|
|
177
|
+
await server.connect(transport);
|
|
178
|
+
await transport.handleRequest(req, res, req.body);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const handler = createMcpHandler(factory, { legacy: 'reject', responseMode: 'json', onerror: logError });
|
|
183
|
+
res.on('close', () => { handler.close().catch(() => {}); });
|
|
184
|
+
await toNodeHandler(handler, { onerror: logError })(req, res, req.body);
|
|
174
185
|
} catch (err) {
|
|
175
|
-
|
|
186
|
+
logError(err);
|
|
176
187
|
if (!res.headersSent) {
|
|
177
188
|
res.status(500).json({
|
|
178
189
|
jsonrpc: '2.0',
|
package/mcp-mode.js
CHANGED
|
@@ -2,12 +2,8 @@
|
|
|
2
2
|
import 'dotenv/config';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { Server } from '@modelcontextprotocol/
|
|
6
|
-
import { StdioServerTransport } from '@modelcontextprotocol/
|
|
7
|
-
import {
|
|
8
|
-
ListToolsRequestSchema,
|
|
9
|
-
CallToolRequestSchema,
|
|
10
|
-
} from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { Server } from '@modelcontextprotocol/server';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
11
7
|
import {
|
|
12
8
|
EXEC_ARG, MAX_EXEC_ROUNDS, parseExecRoutes, normalizeRoute, execAnnouncement, execRequestOf, runExecRequest,
|
|
13
9
|
} from './gateway-exec.js';
|
|
@@ -705,9 +701,9 @@ if (initialProxyTools.length) {
|
|
|
705
701
|
console.error(`[mcp-mode] ${initialProxyTools.length} proxy tool(s) exposed directly from gateway`);
|
|
706
702
|
}
|
|
707
703
|
|
|
708
|
-
server.setRequestHandler(
|
|
704
|
+
server.setRequestHandler('tools/list', async () => ({ tools: allTools }));
|
|
709
705
|
|
|
710
|
-
server.setRequestHandler(
|
|
706
|
+
server.setRequestHandler('tools/call', async (request) => {
|
|
711
707
|
const { name, arguments: args = {} } = request.params;
|
|
712
708
|
|
|
713
709
|
try {
|
package/mcp-registry.js
CHANGED
|
@@ -1,250 +1,250 @@
|
|
|
1
|
-
import crypto from 'crypto';
|
|
2
|
-
import readline from 'readline';
|
|
3
|
-
import { Client } from '@modelcontextprotocol/
|
|
4
|
-
import { StdioClientTransport } from '@modelcontextprotocol/
|
|
5
|
-
import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
|
|
6
|
-
import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix } from './http-relay.js';
|
|
7
|
-
|
|
8
|
-
// --- Registre des MCP hébergés par le gateway ---
|
|
9
|
-
//
|
|
10
|
-
// Le registre est la seule source de vérité sur les MCP en service : les routes REST, le
|
|
11
|
-
// relais HTTP, l'endpoint /mcp et la pré-exposition le consultent à chaque requête.
|
|
12
|
-
//
|
|
13
|
-
// `apply(servers)` aligne le registre sur une configuration `mcpServers` :
|
|
14
|
-
// - MCP absent de la configuration → son processus est arrêté ;
|
|
15
|
-
// - MCP nouveau, ou pas en service → il est lancé ;
|
|
16
|
-
// - command, args ou env modifiés → seul ce MCP est relancé ;
|
|
17
|
-
// - description ou EXPOSE_AT_AGENT_STARTUP seuls modifiés → mis à jour sans relance.
|
|
18
|
-
// Les autres MCP ne sont pas touchés.
|
|
19
|
-
//
|
|
20
|
-
// Chaque changement effectif incrémente la révision, que le gateway renvoie dans l'en-tête
|
|
21
|
-
// `X-Gateway-Revision` : le relais stdio (mcp-mode.js) s'en sert pour rafraîchir ses outils.
|
|
22
|
-
|
|
23
|
-
export const REVISION_HEADER = 'X-Gateway-Revision';
|
|
24
|
-
|
|
25
|
-
// Variables système minimales transmises aux MCPs enfants.
|
|
26
|
-
// On exclut délibérément toutes les variables propres au gateway
|
|
27
|
-
// (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
|
|
28
|
-
const CHILD_ENV_WHITELIST = new Set([
|
|
29
|
-
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
|
|
30
|
-
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
31
|
-
'TEMP', 'TMP', 'TMPDIR',
|
|
32
|
-
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
33
|
-
'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
|
|
34
|
-
'COMSPEC', 'PATHEXT', 'USERNAME',
|
|
35
|
-
'PROGRAMFILES', 'NODE_PATH',
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
// Directives lues par le gateway seul : les modifier ne justifie pas de relancer le MCP.
|
|
39
|
-
const GATEWAY_ONLY_KEYS = new Set(['EXPOSE_AT_AGENT_STARTUP']);
|
|
40
|
-
|
|
41
|
-
function buildChildEnv(cfgEnv = {}) {
|
|
42
|
-
const safeEnv = Object.fromEntries(
|
|
43
|
-
Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
|
|
44
|
-
);
|
|
45
|
-
return { ...safeEnv, ...cfgEnv };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Préfixe des lignes de stderr d'un MCP : `[mcp "<nom>"] `, le nom en chaîne JSON. */
|
|
49
|
-
export function stderrPrefix(name) {
|
|
50
|
-
return `[mcp ${JSON.stringify(name)}] `;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Recopie le stderr d'un MCP sur celui du gateway, ligne par ligne, chacune préfixée par le nom du MCP. */
|
|
54
|
-
function relayStderr(name, stream) {
|
|
55
|
-
const prefix = stderrPrefix(name);
|
|
56
|
-
readline.createInterface({ input: stream, crlfDelay: Infinity }).on('line', (line) => {
|
|
57
|
-
process.stderr.write(`${prefix}${line}\n`);
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function connectMcp(name, cfg) {
|
|
62
|
-
const transport = new StdioClientTransport({
|
|
63
|
-
command: cfg.command,
|
|
64
|
-
args: cfg.args ?? [],
|
|
65
|
-
env: buildChildEnv(cfg.env),
|
|
66
|
-
stderr: 'pipe',
|
|
67
|
-
});
|
|
68
|
-
relayStderr(name, transport.stderr);
|
|
69
|
-
const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
|
|
70
|
-
await client.connect(transport);
|
|
71
|
-
console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
|
|
72
|
-
return client;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Empreinte de ce qui définit le processus enfant : la changer impose de le relancer. */
|
|
76
|
-
function processKey(cfg) {
|
|
77
|
-
const env = Object.entries(cfg.env ?? {})
|
|
78
|
-
.filter(([key]) => !GATEWAY_ONLY_KEYS.has(key))
|
|
79
|
-
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
80
|
-
return JSON.stringify([cfg.command, cfg.args ?? [], env]);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Lit les directives gateway d'un MCP. Lève une erreur si le MCP ne peut pas être servi.
|
|
85
|
-
*/
|
|
86
|
-
function readDirectives(name, cfg) {
|
|
87
|
-
const route = cfg.env?.MCP_SERVER_ROUTE;
|
|
88
|
-
if (!route) throw new Error('no MCP_SERVER_ROUTE defined');
|
|
89
|
-
|
|
90
|
-
const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
|
|
91
|
-
const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
|
|
92
|
-
if (!valid) {
|
|
93
|
-
console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
|
|
97
|
-
const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
|
|
98
|
-
if (!relayPortValid) {
|
|
99
|
-
throw new Error(`invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return { route, exposeAtStartup, relayPort, description: cfg.description ?? null };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export class McpRegistry {
|
|
106
|
-
/** Entrées en service, dans l'ordre de la configuration. */
|
|
107
|
-
entries = [];
|
|
108
|
-
proxyTools = [];
|
|
109
|
-
/** Préfixes `<route>/http` des MCP qui déclarent un relais HTTP. */
|
|
110
|
-
relayPrefixes = new Set();
|
|
111
|
-
|
|
112
|
-
#bootId = crypto.randomUUID().slice(0, 8);
|
|
113
|
-
#generation = 0;
|
|
114
|
-
#queue = Promise.resolve();
|
|
115
|
-
#onToolsLoaded;
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* @param {{ onToolsLoaded?: (name: string, tools: object[]) => void }} options
|
|
119
|
-
* `onToolsLoaded` est appelé à chaque lancement d'un MCP, avec ses outils.
|
|
120
|
-
*/
|
|
121
|
-
constructor({ onToolsLoaded } = {}) {
|
|
122
|
-
this.#onToolsLoaded = onToolsLoaded ?? (() => {});
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Identifiant de l'état servi : change à chaque démarrage du gateway et à chaque changement effectif. */
|
|
126
|
-
get revision() {
|
|
127
|
-
return `${this.#bootId}.${this.#generation}`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
byRoute(route) {
|
|
131
|
-
return this.entries.find((entry) => entry.route === route) ?? null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
byName(name) {
|
|
135
|
-
return this.entries.find((entry) => entry.name === name) ?? null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Aligne le registre sur `servers` (le bloc `mcpServers` de la configuration).
|
|
140
|
-
* Les appels sont sérialisés. Rend une ligne par MCP concerné :
|
|
141
|
-
* `{ name, action, ok, error? }`, `action` ∈ started | restarted | stopped | updated | unchanged.
|
|
142
|
-
*/
|
|
143
|
-
apply(servers) {
|
|
144
|
-
const run = this.#queue.then(() => this.#apply(servers));
|
|
145
|
-
this.#queue = run.catch(() => {});
|
|
146
|
-
return run;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
async closeAll() {
|
|
150
|
-
await Promise.allSettled(this.entries.map((entry) => entry.client.close()));
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
async #apply(servers) {
|
|
154
|
-
const results = [];
|
|
155
|
-
const wanted = new Map(Object.entries(servers ?? {}));
|
|
156
|
-
const restarting = new Set();
|
|
157
|
-
let changed = false;
|
|
158
|
-
|
|
159
|
-
// Arrêts d'abord : un MCP relancé ou une route reprise ne doivent pas croiser l'ancien processus.
|
|
160
|
-
for (const entry of [...this.entries]) {
|
|
161
|
-
const cfg = wanted.get(entry.name);
|
|
162
|
-
if (cfg && processKey(cfg) === entry.processKey) continue;
|
|
163
|
-
await this.#stop(entry);
|
|
164
|
-
changed = true;
|
|
165
|
-
if (cfg) restarting.add(entry.name);
|
|
166
|
-
else results.push({ name: entry.name, action: 'stopped', ok: true });
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
for (const [name, cfg] of wanted) {
|
|
170
|
-
const current = this.byName(name);
|
|
171
|
-
|
|
172
|
-
if (current) {
|
|
173
|
-
const { description, exposeAtStartup } = readDirectives(name, cfg);
|
|
174
|
-
const updated = current.description !== description || current.exposeAtStartup !== exposeAtStartup;
|
|
175
|
-
current.description = description;
|
|
176
|
-
current.exposeAtStartup = exposeAtStartup;
|
|
177
|
-
if (updated) changed = true;
|
|
178
|
-
results.push({ name, action: updated ? 'updated' : 'unchanged', ok: true });
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const action = restarting.has(name) ? 'restarted' : 'started';
|
|
183
|
-
try {
|
|
184
|
-
await this.#start(name, cfg);
|
|
185
|
-
changed = true;
|
|
186
|
-
results.push({ name, action, ok: true });
|
|
187
|
-
} catch (err) {
|
|
188
|
-
console.error(`[gateway] Failed to start "${name}": ${err.message}`);
|
|
189
|
-
results.push({ name, action, ok: false, error: err.message });
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Ordre de la configuration, conservé pour list_mcps, /mcps et la pré-exposition.
|
|
194
|
-
const order = [...wanted.keys()];
|
|
195
|
-
this.entries.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
|
196
|
-
|
|
197
|
-
if (changed) {
|
|
198
|
-
this.#generation += 1;
|
|
199
|
-
this.#refreshDerived();
|
|
200
|
-
}
|
|
201
|
-
return results;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
async #start(name, cfg) {
|
|
205
|
-
const directives = readDirectives(name, cfg);
|
|
206
|
-
if (this.byRoute(directives.route)) {
|
|
207
|
-
throw new Error(`route ${directives.route} already served by "${this.byRoute(directives.route).name}"`);
|
|
208
|
-
}
|
|
209
|
-
const client = await connectMcp(name, cfg);
|
|
210
|
-
let tools;
|
|
211
|
-
try {
|
|
212
|
-
({ tools } = await client.listTools());
|
|
213
|
-
} catch (err) {
|
|
214
|
-
await client.close().catch(() => {});
|
|
215
|
-
throw err;
|
|
216
|
-
}
|
|
217
|
-
this.entries.push({ name, ...directives, tools, client, processKey: processKey(cfg) });
|
|
218
|
-
if (directives.relayPort) {
|
|
219
|
-
console.log(`[gateway] HTTP relay: ${relayPrefix(directives.route)}/* → 127.0.0.1:${directives.relayPort} (${name})`);
|
|
220
|
-
}
|
|
221
|
-
this.#onToolsLoaded(name, tools);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async #stop(entry) {
|
|
225
|
-
this.entries = this.entries.filter((e) => e !== entry);
|
|
226
|
-
try {
|
|
227
|
-
await entry.client.close();
|
|
228
|
-
} catch (err) {
|
|
229
|
-
console.error(`[gateway] Error while closing "${entry.name}":`, err.message);
|
|
230
|
-
}
|
|
231
|
-
console.log(`[gateway] Stopped: ${entry.name}`);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
#refreshDerived() {
|
|
235
|
-
this.relayPrefixes = new Set(
|
|
236
|
-
this.entries.filter((e) => e.relayPort).map((e) => relayPrefix(e.route))
|
|
237
|
-
);
|
|
238
|
-
this.proxyTools = buildProxyTools(this.entries);
|
|
239
|
-
|
|
240
|
-
const preExposed = this.entries.filter((e) => e.exposeAtStartup);
|
|
241
|
-
if (preExposed.length) {
|
|
242
|
-
console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map((e) => e.name).join(', ')} — ${this.proxyTools.length} tool(s) published directly in tools/list`);
|
|
243
|
-
}
|
|
244
|
-
const overlong = overlongProxyNames(this.proxyTools);
|
|
245
|
-
if (overlong.length) {
|
|
246
|
-
console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
|
|
247
|
-
}
|
|
248
|
-
console.log(`[gateway] Revision ${this.revision} — ${this.entries.length} MCP(s) in service`);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import readline from 'readline';
|
|
3
|
+
import { Client } from '@modelcontextprotocol/client';
|
|
4
|
+
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
|
|
5
|
+
import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
|
|
6
|
+
import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix } from './http-relay.js';
|
|
7
|
+
|
|
8
|
+
// --- Registre des MCP hébergés par le gateway ---
|
|
9
|
+
//
|
|
10
|
+
// Le registre est la seule source de vérité sur les MCP en service : les routes REST, le
|
|
11
|
+
// relais HTTP, l'endpoint /mcp et la pré-exposition le consultent à chaque requête.
|
|
12
|
+
//
|
|
13
|
+
// `apply(servers)` aligne le registre sur une configuration `mcpServers` :
|
|
14
|
+
// - MCP absent de la configuration → son processus est arrêté ;
|
|
15
|
+
// - MCP nouveau, ou pas en service → il est lancé ;
|
|
16
|
+
// - command, args ou env modifiés → seul ce MCP est relancé ;
|
|
17
|
+
// - description ou EXPOSE_AT_AGENT_STARTUP seuls modifiés → mis à jour sans relance.
|
|
18
|
+
// Les autres MCP ne sont pas touchés.
|
|
19
|
+
//
|
|
20
|
+
// Chaque changement effectif incrémente la révision, que le gateway renvoie dans l'en-tête
|
|
21
|
+
// `X-Gateway-Revision` : le relais stdio (mcp-mode.js) s'en sert pour rafraîchir ses outils.
|
|
22
|
+
|
|
23
|
+
export const REVISION_HEADER = 'X-Gateway-Revision';
|
|
24
|
+
|
|
25
|
+
// Variables système minimales transmises aux MCPs enfants.
|
|
26
|
+
// On exclut délibérément toutes les variables propres au gateway
|
|
27
|
+
// (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
|
|
28
|
+
const CHILD_ENV_WHITELIST = new Set([
|
|
29
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
|
|
30
|
+
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
31
|
+
'TEMP', 'TMP', 'TMPDIR',
|
|
32
|
+
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
33
|
+
'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
|
|
34
|
+
'COMSPEC', 'PATHEXT', 'USERNAME',
|
|
35
|
+
'PROGRAMFILES', 'NODE_PATH',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Directives lues par le gateway seul : les modifier ne justifie pas de relancer le MCP.
|
|
39
|
+
const GATEWAY_ONLY_KEYS = new Set(['EXPOSE_AT_AGENT_STARTUP']);
|
|
40
|
+
|
|
41
|
+
function buildChildEnv(cfgEnv = {}) {
|
|
42
|
+
const safeEnv = Object.fromEntries(
|
|
43
|
+
Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
|
|
44
|
+
);
|
|
45
|
+
return { ...safeEnv, ...cfgEnv };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Préfixe des lignes de stderr d'un MCP : `[mcp "<nom>"] `, le nom en chaîne JSON. */
|
|
49
|
+
export function stderrPrefix(name) {
|
|
50
|
+
return `[mcp ${JSON.stringify(name)}] `;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Recopie le stderr d'un MCP sur celui du gateway, ligne par ligne, chacune préfixée par le nom du MCP. */
|
|
54
|
+
function relayStderr(name, stream) {
|
|
55
|
+
const prefix = stderrPrefix(name);
|
|
56
|
+
readline.createInterface({ input: stream, crlfDelay: Infinity }).on('line', (line) => {
|
|
57
|
+
process.stderr.write(`${prefix}${line}\n`);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function connectMcp(name, cfg) {
|
|
62
|
+
const transport = new StdioClientTransport({
|
|
63
|
+
command: cfg.command,
|
|
64
|
+
args: cfg.args ?? [],
|
|
65
|
+
env: buildChildEnv(cfg.env),
|
|
66
|
+
stderr: 'pipe',
|
|
67
|
+
});
|
|
68
|
+
relayStderr(name, transport.stderr);
|
|
69
|
+
const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
|
|
70
|
+
await client.connect(transport);
|
|
71
|
+
console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
|
|
72
|
+
return client;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Empreinte de ce qui définit le processus enfant : la changer impose de le relancer. */
|
|
76
|
+
function processKey(cfg) {
|
|
77
|
+
const env = Object.entries(cfg.env ?? {})
|
|
78
|
+
.filter(([key]) => !GATEWAY_ONLY_KEYS.has(key))
|
|
79
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
80
|
+
return JSON.stringify([cfg.command, cfg.args ?? [], env]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Lit les directives gateway d'un MCP. Lève une erreur si le MCP ne peut pas être servi.
|
|
85
|
+
*/
|
|
86
|
+
function readDirectives(name, cfg) {
|
|
87
|
+
const route = cfg.env?.MCP_SERVER_ROUTE;
|
|
88
|
+
if (!route) throw new Error('no MCP_SERVER_ROUTE defined');
|
|
89
|
+
|
|
90
|
+
const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
|
|
91
|
+
const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
|
|
92
|
+
if (!valid) {
|
|
93
|
+
console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
|
|
97
|
+
const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
|
|
98
|
+
if (!relayPortValid) {
|
|
99
|
+
throw new Error(`invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { route, exposeAtStartup, relayPort, description: cfg.description ?? null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class McpRegistry {
|
|
106
|
+
/** Entrées en service, dans l'ordre de la configuration. */
|
|
107
|
+
entries = [];
|
|
108
|
+
proxyTools = [];
|
|
109
|
+
/** Préfixes `<route>/http` des MCP qui déclarent un relais HTTP. */
|
|
110
|
+
relayPrefixes = new Set();
|
|
111
|
+
|
|
112
|
+
#bootId = crypto.randomUUID().slice(0, 8);
|
|
113
|
+
#generation = 0;
|
|
114
|
+
#queue = Promise.resolve();
|
|
115
|
+
#onToolsLoaded;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {{ onToolsLoaded?: (name: string, tools: object[]) => void }} options
|
|
119
|
+
* `onToolsLoaded` est appelé à chaque lancement d'un MCP, avec ses outils.
|
|
120
|
+
*/
|
|
121
|
+
constructor({ onToolsLoaded } = {}) {
|
|
122
|
+
this.#onToolsLoaded = onToolsLoaded ?? (() => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Identifiant de l'état servi : change à chaque démarrage du gateway et à chaque changement effectif. */
|
|
126
|
+
get revision() {
|
|
127
|
+
return `${this.#bootId}.${this.#generation}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
byRoute(route) {
|
|
131
|
+
return this.entries.find((entry) => entry.route === route) ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
byName(name) {
|
|
135
|
+
return this.entries.find((entry) => entry.name === name) ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Aligne le registre sur `servers` (le bloc `mcpServers` de la configuration).
|
|
140
|
+
* Les appels sont sérialisés. Rend une ligne par MCP concerné :
|
|
141
|
+
* `{ name, action, ok, error? }`, `action` ∈ started | restarted | stopped | updated | unchanged.
|
|
142
|
+
*/
|
|
143
|
+
apply(servers) {
|
|
144
|
+
const run = this.#queue.then(() => this.#apply(servers));
|
|
145
|
+
this.#queue = run.catch(() => {});
|
|
146
|
+
return run;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async closeAll() {
|
|
150
|
+
await Promise.allSettled(this.entries.map((entry) => entry.client.close()));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async #apply(servers) {
|
|
154
|
+
const results = [];
|
|
155
|
+
const wanted = new Map(Object.entries(servers ?? {}));
|
|
156
|
+
const restarting = new Set();
|
|
157
|
+
let changed = false;
|
|
158
|
+
|
|
159
|
+
// Arrêts d'abord : un MCP relancé ou une route reprise ne doivent pas croiser l'ancien processus.
|
|
160
|
+
for (const entry of [...this.entries]) {
|
|
161
|
+
const cfg = wanted.get(entry.name);
|
|
162
|
+
if (cfg && processKey(cfg) === entry.processKey) continue;
|
|
163
|
+
await this.#stop(entry);
|
|
164
|
+
changed = true;
|
|
165
|
+
if (cfg) restarting.add(entry.name);
|
|
166
|
+
else results.push({ name: entry.name, action: 'stopped', ok: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const [name, cfg] of wanted) {
|
|
170
|
+
const current = this.byName(name);
|
|
171
|
+
|
|
172
|
+
if (current) {
|
|
173
|
+
const { description, exposeAtStartup } = readDirectives(name, cfg);
|
|
174
|
+
const updated = current.description !== description || current.exposeAtStartup !== exposeAtStartup;
|
|
175
|
+
current.description = description;
|
|
176
|
+
current.exposeAtStartup = exposeAtStartup;
|
|
177
|
+
if (updated) changed = true;
|
|
178
|
+
results.push({ name, action: updated ? 'updated' : 'unchanged', ok: true });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const action = restarting.has(name) ? 'restarted' : 'started';
|
|
183
|
+
try {
|
|
184
|
+
await this.#start(name, cfg);
|
|
185
|
+
changed = true;
|
|
186
|
+
results.push({ name, action, ok: true });
|
|
187
|
+
} catch (err) {
|
|
188
|
+
console.error(`[gateway] Failed to start "${name}": ${err.message}`);
|
|
189
|
+
results.push({ name, action, ok: false, error: err.message });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Ordre de la configuration, conservé pour list_mcps, /mcps et la pré-exposition.
|
|
194
|
+
const order = [...wanted.keys()];
|
|
195
|
+
this.entries.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
|
196
|
+
|
|
197
|
+
if (changed) {
|
|
198
|
+
this.#generation += 1;
|
|
199
|
+
this.#refreshDerived();
|
|
200
|
+
}
|
|
201
|
+
return results;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async #start(name, cfg) {
|
|
205
|
+
const directives = readDirectives(name, cfg);
|
|
206
|
+
if (this.byRoute(directives.route)) {
|
|
207
|
+
throw new Error(`route ${directives.route} already served by "${this.byRoute(directives.route).name}"`);
|
|
208
|
+
}
|
|
209
|
+
const client = await connectMcp(name, cfg);
|
|
210
|
+
let tools;
|
|
211
|
+
try {
|
|
212
|
+
({ tools } = await client.listTools());
|
|
213
|
+
} catch (err) {
|
|
214
|
+
await client.close().catch(() => {});
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
this.entries.push({ name, ...directives, tools, client, processKey: processKey(cfg) });
|
|
218
|
+
if (directives.relayPort) {
|
|
219
|
+
console.log(`[gateway] HTTP relay: ${relayPrefix(directives.route)}/* → 127.0.0.1:${directives.relayPort} (${name})`);
|
|
220
|
+
}
|
|
221
|
+
this.#onToolsLoaded(name, tools);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #stop(entry) {
|
|
225
|
+
this.entries = this.entries.filter((e) => e !== entry);
|
|
226
|
+
try {
|
|
227
|
+
await entry.client.close();
|
|
228
|
+
} catch (err) {
|
|
229
|
+
console.error(`[gateway] Error while closing "${entry.name}":`, err.message);
|
|
230
|
+
}
|
|
231
|
+
console.log(`[gateway] Stopped: ${entry.name}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
#refreshDerived() {
|
|
235
|
+
this.relayPrefixes = new Set(
|
|
236
|
+
this.entries.filter((e) => e.relayPort).map((e) => relayPrefix(e.route))
|
|
237
|
+
);
|
|
238
|
+
this.proxyTools = buildProxyTools(this.entries);
|
|
239
|
+
|
|
240
|
+
const preExposed = this.entries.filter((e) => e.exposeAtStartup);
|
|
241
|
+
if (preExposed.length) {
|
|
242
|
+
console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map((e) => e.name).join(', ')} — ${this.proxyTools.length} tool(s) published directly in tools/list`);
|
|
243
|
+
}
|
|
244
|
+
const overlong = overlongProxyNames(this.proxyTools);
|
|
245
|
+
if (overlong.length) {
|
|
246
|
+
console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
|
|
247
|
+
}
|
|
248
|
+
console.log(`[gateway] Revision ${this.revision} — ${this.entries.length} MCP(s) in service`);
|
|
249
|
+
}
|
|
250
|
+
}
|
package/package.json
CHANGED
|
@@ -1,59 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@imenam/mcp-http-gateway",
|
|
3
|
-
"version": "2.0.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"description": "HTTP gateway that exposes MCP servers as REST routes — bridge between MCP and any HTTP-capable agent",
|
|
6
|
-
"main": "server.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"mcp-http-gateway": "server.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"server.js",
|
|
12
|
-
"mcp-mode.js",
|
|
13
|
-
"mcp-endpoint.js",
|
|
14
|
-
"auth.js",
|
|
15
|
-
"README.md",
|
|
16
|
-
"embeddings/",
|
|
17
|
-
"startup-exposition.js",
|
|
18
|
-
"http-relay.js",
|
|
19
|
-
"gateway-exec.js",
|
|
20
|
-
"gateway-watch.js",
|
|
21
|
-
"mcp-registry.js",
|
|
22
|
-
"scope.js"
|
|
23
|
-
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"start": "node server.js"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"@modelcontextprotocol/
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
|
|
59
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@imenam/mcp-http-gateway",
|
|
3
|
+
"version": "2.0.24",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "HTTP gateway that exposes MCP servers as REST routes — bridge between MCP and any HTTP-capable agent",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mcp-http-gateway": "server.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"server.js",
|
|
12
|
+
"mcp-mode.js",
|
|
13
|
+
"mcp-endpoint.js",
|
|
14
|
+
"auth.js",
|
|
15
|
+
"README.md",
|
|
16
|
+
"embeddings/",
|
|
17
|
+
"startup-exposition.js",
|
|
18
|
+
"http-relay.js",
|
|
19
|
+
"gateway-exec.js",
|
|
20
|
+
"gateway-watch.js",
|
|
21
|
+
"mcp-registry.js",
|
|
22
|
+
"scope.js"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"start": "node server.js"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"mcp",
|
|
29
|
+
"model-context-protocol",
|
|
30
|
+
"gateway",
|
|
31
|
+
"http",
|
|
32
|
+
"ai",
|
|
33
|
+
"agent",
|
|
34
|
+
"llm",
|
|
35
|
+
"bridge"
|
|
36
|
+
],
|
|
37
|
+
"author": "Matthieu Pesnot-Pin <matthieupin@gmail.com>",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/Matthieu-Pesnot-Pin/mcp-http-gateway#readme",
|
|
46
|
+
"license": "ISC",
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/client": "^2.4.0",
|
|
52
|
+
"@modelcontextprotocol/express": "^2.0.2",
|
|
53
|
+
"@modelcontextprotocol/node": "^2.1.2",
|
|
54
|
+
"@modelcontextprotocol/server": "^2.4.0",
|
|
55
|
+
"better-sqlite3": "^12.8.0",
|
|
56
|
+
"dotenv": "^17.3.1",
|
|
57
|
+
"express": "^5.2.1",
|
|
58
|
+
"express-rate-limit": "^8.7.1",
|
|
59
|
+
"jose": "^6.2.3",
|
|
60
|
+
"openai": "^6.31.0"
|
|
61
|
+
}
|
|
62
|
+
}
|
package/server.js
CHANGED
|
@@ -26,7 +26,7 @@ import { McpRegistry, REVISION_HEADER } from './mcp-registry.js';
|
|
|
26
26
|
import {
|
|
27
27
|
FULL_SCOPE, SCOPE_HEADER, allowsMcp, allowsTool, filterTools, readScope, scopedRevision,
|
|
28
28
|
} from './scope.js';
|
|
29
|
-
import { requireBearerAuth } from '@modelcontextprotocol/
|
|
29
|
+
import { requireBearerAuth } from '@modelcontextprotocol/express';
|
|
30
30
|
import { rateLimit } from 'express-rate-limit';
|
|
31
31
|
|
|
32
32
|
// Normalise les différents formats de config rencontrés dans la nature :
|