@imenam/mcp-http-gateway 2.0.14 → 2.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +707 -512
- package/gateway-exec.js +162 -0
- package/gateway-watch.js +348 -0
- package/http-relay.js +93 -0
- package/mcp-endpoint.js +8 -7
- package/mcp-mode.js +763 -647
- package/mcp-registry.js +234 -0
- package/package.json +58 -54
- package/server.js +120 -114
package/mcp-registry.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
3
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
4
|
+
import { buildProxyTools, overlongProxyNames, parseExposeFlag } from './startup-exposition.js';
|
|
5
|
+
import { HTTP_RELAY_PORT_KEY, parseRelayPort, relayPrefix } from './http-relay.js';
|
|
6
|
+
|
|
7
|
+
// --- Registre des MCP hébergés par le gateway ---
|
|
8
|
+
//
|
|
9
|
+
// Le registre est la seule source de vérité sur les MCP en service : les routes REST, le
|
|
10
|
+
// relais HTTP, l'endpoint /mcp et la pré-exposition le consultent à chaque requête.
|
|
11
|
+
//
|
|
12
|
+
// `apply(servers)` aligne le registre sur une configuration `mcpServers` :
|
|
13
|
+
// - MCP absent de la configuration → son processus est arrêté ;
|
|
14
|
+
// - MCP nouveau, ou pas en service → il est lancé ;
|
|
15
|
+
// - command, args ou env modifiés → seul ce MCP est relancé ;
|
|
16
|
+
// - description ou EXPOSE_AT_AGENT_STARTUP seuls modifiés → mis à jour sans relance.
|
|
17
|
+
// Les autres MCP ne sont pas touchés.
|
|
18
|
+
//
|
|
19
|
+
// Chaque changement effectif incrémente la révision, que le gateway renvoie dans l'en-tête
|
|
20
|
+
// `X-Gateway-Revision` : le relais stdio (mcp-mode.js) s'en sert pour rafraîchir ses outils.
|
|
21
|
+
|
|
22
|
+
export const REVISION_HEADER = 'X-Gateway-Revision';
|
|
23
|
+
|
|
24
|
+
// Variables système minimales transmises aux MCPs enfants.
|
|
25
|
+
// On exclut délibérément toutes les variables propres au gateway
|
|
26
|
+
// (OPENAI_API_KEY, OAUTH_*, GATEWAY_*, MCP_*, etc.).
|
|
27
|
+
const CHILD_ENV_WHITELIST = new Set([
|
|
28
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
|
|
29
|
+
'LANG', 'LC_ALL', 'LC_CTYPE',
|
|
30
|
+
'TEMP', 'TMP', 'TMPDIR',
|
|
31
|
+
'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
|
|
32
|
+
'SYSTEMROOT', 'SYSTEMDRIVE', 'WINDIR',
|
|
33
|
+
'COMSPEC', 'PATHEXT', 'USERNAME',
|
|
34
|
+
'PROGRAMFILES', 'NODE_PATH',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Directives lues par le gateway seul : les modifier ne justifie pas de relancer le MCP.
|
|
38
|
+
const GATEWAY_ONLY_KEYS = new Set(['EXPOSE_AT_AGENT_STARTUP']);
|
|
39
|
+
|
|
40
|
+
function buildChildEnv(cfgEnv = {}) {
|
|
41
|
+
const safeEnv = Object.fromEntries(
|
|
42
|
+
Object.entries(process.env).filter(([k]) => CHILD_ENV_WHITELIST.has(k))
|
|
43
|
+
);
|
|
44
|
+
return { ...safeEnv, ...cfgEnv };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function connectMcp(name, cfg) {
|
|
48
|
+
const transport = new StdioClientTransport({
|
|
49
|
+
command: cfg.command,
|
|
50
|
+
args: cfg.args ?? [],
|
|
51
|
+
env: buildChildEnv(cfg.env),
|
|
52
|
+
});
|
|
53
|
+
const client = new Client({ name: 'mcp-gateway', version: '1.0.0' }, { capabilities: {} });
|
|
54
|
+
await client.connect(transport);
|
|
55
|
+
console.log(`[gateway] Connected: ${name} → ${cfg.env?.MCP_SERVER_ROUTE}`);
|
|
56
|
+
return client;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Empreinte de ce qui définit le processus enfant : la changer impose de le relancer. */
|
|
60
|
+
function processKey(cfg) {
|
|
61
|
+
const env = Object.entries(cfg.env ?? {})
|
|
62
|
+
.filter(([key]) => !GATEWAY_ONLY_KEYS.has(key))
|
|
63
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
64
|
+
return JSON.stringify([cfg.command, cfg.args ?? [], env]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Lit les directives gateway d'un MCP. Lève une erreur si le MCP ne peut pas être servi.
|
|
69
|
+
*/
|
|
70
|
+
function readDirectives(name, cfg) {
|
|
71
|
+
const route = cfg.env?.MCP_SERVER_ROUTE;
|
|
72
|
+
if (!route) throw new Error('no MCP_SERVER_ROUTE defined');
|
|
73
|
+
|
|
74
|
+
const rawExpose = cfg.env?.EXPOSE_AT_AGENT_STARTUP;
|
|
75
|
+
const { value: exposeAtStartup, valid } = parseExposeFlag(rawExpose);
|
|
76
|
+
if (!valid) {
|
|
77
|
+
console.warn(`[gateway] "${name}": invalid EXPOSE_AT_AGENT_STARTUP="${rawExpose}" (expected true | false) — not pre-exposed`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rawRelayPort = cfg.env?.[HTTP_RELAY_PORT_KEY];
|
|
81
|
+
const { port: relayPort, valid: relayPortValid } = parseRelayPort(rawRelayPort);
|
|
82
|
+
if (!relayPortValid) {
|
|
83
|
+
throw new Error(`invalid ${HTTP_RELAY_PORT_KEY}="${rawRelayPort}" (expected a port 1-65535)`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { route, exposeAtStartup, relayPort, description: cfg.description ?? null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class McpRegistry {
|
|
90
|
+
/** Entrées en service, dans l'ordre de la configuration. */
|
|
91
|
+
entries = [];
|
|
92
|
+
proxyTools = [];
|
|
93
|
+
/** Préfixes `<route>/http` des MCP qui déclarent un relais HTTP. */
|
|
94
|
+
relayPrefixes = new Set();
|
|
95
|
+
|
|
96
|
+
#bootId = crypto.randomUUID().slice(0, 8);
|
|
97
|
+
#generation = 0;
|
|
98
|
+
#queue = Promise.resolve();
|
|
99
|
+
#onToolsLoaded;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {{ onToolsLoaded?: (name: string, tools: object[]) => void }} options
|
|
103
|
+
* `onToolsLoaded` est appelé à chaque lancement d'un MCP, avec ses outils.
|
|
104
|
+
*/
|
|
105
|
+
constructor({ onToolsLoaded } = {}) {
|
|
106
|
+
this.#onToolsLoaded = onToolsLoaded ?? (() => {});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Identifiant de l'état servi : change à chaque démarrage du gateway et à chaque changement effectif. */
|
|
110
|
+
get revision() {
|
|
111
|
+
return `${this.#bootId}.${this.#generation}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
byRoute(route) {
|
|
115
|
+
return this.entries.find((entry) => entry.route === route) ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
byName(name) {
|
|
119
|
+
return this.entries.find((entry) => entry.name === name) ?? null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Aligne le registre sur `servers` (le bloc `mcpServers` de la configuration).
|
|
124
|
+
* Les appels sont sérialisés. Rend une ligne par MCP concerné :
|
|
125
|
+
* `{ name, action, ok, error? }`, `action` ∈ started | restarted | stopped | updated | unchanged.
|
|
126
|
+
*/
|
|
127
|
+
apply(servers) {
|
|
128
|
+
const run = this.#queue.then(() => this.#apply(servers));
|
|
129
|
+
this.#queue = run.catch(() => {});
|
|
130
|
+
return run;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async closeAll() {
|
|
134
|
+
await Promise.allSettled(this.entries.map((entry) => entry.client.close()));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async #apply(servers) {
|
|
138
|
+
const results = [];
|
|
139
|
+
const wanted = new Map(Object.entries(servers ?? {}));
|
|
140
|
+
const restarting = new Set();
|
|
141
|
+
let changed = false;
|
|
142
|
+
|
|
143
|
+
// Arrêts d'abord : un MCP relancé ou une route reprise ne doivent pas croiser l'ancien processus.
|
|
144
|
+
for (const entry of [...this.entries]) {
|
|
145
|
+
const cfg = wanted.get(entry.name);
|
|
146
|
+
if (cfg && processKey(cfg) === entry.processKey) continue;
|
|
147
|
+
await this.#stop(entry);
|
|
148
|
+
changed = true;
|
|
149
|
+
if (cfg) restarting.add(entry.name);
|
|
150
|
+
else results.push({ name: entry.name, action: 'stopped', ok: true });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const [name, cfg] of wanted) {
|
|
154
|
+
const current = this.byName(name);
|
|
155
|
+
|
|
156
|
+
if (current) {
|
|
157
|
+
const { description, exposeAtStartup } = readDirectives(name, cfg);
|
|
158
|
+
const updated = current.description !== description || current.exposeAtStartup !== exposeAtStartup;
|
|
159
|
+
current.description = description;
|
|
160
|
+
current.exposeAtStartup = exposeAtStartup;
|
|
161
|
+
if (updated) changed = true;
|
|
162
|
+
results.push({ name, action: updated ? 'updated' : 'unchanged', ok: true });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const action = restarting.has(name) ? 'restarted' : 'started';
|
|
167
|
+
try {
|
|
168
|
+
await this.#start(name, cfg);
|
|
169
|
+
changed = true;
|
|
170
|
+
results.push({ name, action, ok: true });
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`[gateway] Failed to start "${name}": ${err.message}`);
|
|
173
|
+
results.push({ name, action, ok: false, error: err.message });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ordre de la configuration, conservé pour list_mcps, /mcps et la pré-exposition.
|
|
178
|
+
const order = [...wanted.keys()];
|
|
179
|
+
this.entries.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name));
|
|
180
|
+
|
|
181
|
+
if (changed) {
|
|
182
|
+
this.#generation += 1;
|
|
183
|
+
this.#refreshDerived();
|
|
184
|
+
}
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async #start(name, cfg) {
|
|
189
|
+
const directives = readDirectives(name, cfg);
|
|
190
|
+
if (this.byRoute(directives.route)) {
|
|
191
|
+
throw new Error(`route ${directives.route} already served by "${this.byRoute(directives.route).name}"`);
|
|
192
|
+
}
|
|
193
|
+
const client = await connectMcp(name, cfg);
|
|
194
|
+
let tools;
|
|
195
|
+
try {
|
|
196
|
+
({ tools } = await client.listTools());
|
|
197
|
+
} catch (err) {
|
|
198
|
+
await client.close().catch(() => {});
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
this.entries.push({ name, ...directives, tools, client, processKey: processKey(cfg) });
|
|
202
|
+
if (directives.relayPort) {
|
|
203
|
+
console.log(`[gateway] HTTP relay: ${relayPrefix(directives.route)}/* → 127.0.0.1:${directives.relayPort} (${name})`);
|
|
204
|
+
}
|
|
205
|
+
this.#onToolsLoaded(name, tools);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async #stop(entry) {
|
|
209
|
+
this.entries = this.entries.filter((e) => e !== entry);
|
|
210
|
+
try {
|
|
211
|
+
await entry.client.close();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(`[gateway] Error while closing "${entry.name}":`, err.message);
|
|
214
|
+
}
|
|
215
|
+
console.log(`[gateway] Stopped: ${entry.name}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#refreshDerived() {
|
|
219
|
+
this.relayPrefixes = new Set(
|
|
220
|
+
this.entries.filter((e) => e.relayPort).map((e) => relayPrefix(e.route))
|
|
221
|
+
);
|
|
222
|
+
this.proxyTools = buildProxyTools(this.entries);
|
|
223
|
+
|
|
224
|
+
const preExposed = this.entries.filter((e) => e.exposeAtStartup);
|
|
225
|
+
if (preExposed.length) {
|
|
226
|
+
console.log(`[gateway] Pre-exposed at agent startup: ${preExposed.map((e) => e.name).join(', ')} — ${this.proxyTools.length} tool(s) published directly in tools/list`);
|
|
227
|
+
}
|
|
228
|
+
const overlong = overlongProxyNames(this.proxyTools);
|
|
229
|
+
if (overlong.length) {
|
|
230
|
+
console.warn(`[gateway] WARNING: proxy tool name(s) may exceed client limits once prefixed: ${overlong.join(', ')}`);
|
|
231
|
+
}
|
|
232
|
+
console.log(`[gateway] Revision ${this.revision} — ${this.entries.length} MCP(s) in service`);
|
|
233
|
+
}
|
|
234
|
+
}
|
package/package.json
CHANGED
|
@@ -1,54 +1,58 @@
|
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
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
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@imenam/mcp-http-gateway",
|
|
3
|
+
"version": "2.0.16",
|
|
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
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"start": "node server.js",
|
|
25
|
+
"release": "npm version patch && npm publish --access public && git push origin master --follow-tags"
|
|
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": "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": ">=18"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
52
|
+
"better-sqlite3": "^12.8.0",
|
|
53
|
+
"dotenv": "^17.3.1",
|
|
54
|
+
"express": "^5.2.1",
|
|
55
|
+
"jose": "^6.2.3",
|
|
56
|
+
"openai": "^6.31.0"
|
|
57
|
+
}
|
|
58
|
+
}
|