@eznix/mcp-gateway 1.3.6 → 1.5.2
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 +21 -0
- package/dist/index.js +71 -13
- package/examples/config.json +19 -0
- package/package.json +2 -2
- package/src/config.ts +3 -2
- package/src/connections.ts +36 -1
- package/src/gateway.ts +34 -6
- package/src/types.ts +3 -2
- package/tests/parseEnvironmentVariables.test.ts +192 -0
package/README.md
CHANGED
|
@@ -124,8 +124,29 @@ Each entry specifies:
|
|
|
124
124
|
- `command` (local only): Array with command and arguments to spawn the upstream server
|
|
125
125
|
- `url` (remote only): Full URL of the remote MCP server
|
|
126
126
|
- `transport` (optional, remote only): Override transport detection (`"streamable_http"` or `"websocket"`). Usually auto-detected from URL protocol.
|
|
127
|
+
- `environment` (local only): Environment variables to pass to the spawned process, with `{env:VAR_NAME}` substitution support
|
|
127
128
|
- `enabled`: Set to false to skip connecting to this server
|
|
128
129
|
|
|
130
|
+
#### Environment Variables with Substitution
|
|
131
|
+
|
|
132
|
+
Local MCP servers support environment variable substitution using `{env:VAR_NAME}` syntax:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"jupyter-lab": {
|
|
137
|
+
"type": "local",
|
|
138
|
+
"command": ["uvx", "jupyter-mcp-server@latest"],
|
|
139
|
+
"environment": {
|
|
140
|
+
"JUPYTER_URL": "http://localhost:{env:JUPYTER_PORT}/",
|
|
141
|
+
"JUPYTER_TOKEN": "{env:JUPYTER_TOKEN}",
|
|
142
|
+
"DEBUG": "true"
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The `{env:VAR_NAME}` placeholders are resolved from the current process environment. If an environment variable is not set, it's replaced with an empty string.
|
|
149
|
+
|
|
129
150
|
### Docker
|
|
130
151
|
|
|
131
152
|
Run MCP Gateway in Docker with HTTP transport:
|
package/dist/index.js
CHANGED
|
@@ -5,25 +5,43 @@ var __getProtoOf = Object.getPrototypeOf;
|
|
|
5
5
|
var __defProp = Object.defineProperty;
|
|
6
6
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
7
|
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
function __accessProp(key) {
|
|
9
|
+
return this[key];
|
|
10
|
+
}
|
|
11
|
+
var __toESMCache_node;
|
|
12
|
+
var __toESMCache_esm;
|
|
8
13
|
var __toESM = (mod, isNodeMode, target) => {
|
|
14
|
+
var canCache = mod != null && typeof mod === "object";
|
|
15
|
+
if (canCache) {
|
|
16
|
+
var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
|
|
17
|
+
var cached = cache.get(mod);
|
|
18
|
+
if (cached)
|
|
19
|
+
return cached;
|
|
20
|
+
}
|
|
9
21
|
target = mod != null ? __create(__getProtoOf(mod)) : {};
|
|
10
22
|
const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
|
|
11
23
|
for (let key of __getOwnPropNames(mod))
|
|
12
24
|
if (!__hasOwnProp.call(to, key))
|
|
13
25
|
__defProp(to, key, {
|
|
14
|
-
get: (
|
|
26
|
+
get: __accessProp.bind(mod, key),
|
|
15
27
|
enumerable: true
|
|
16
28
|
});
|
|
29
|
+
if (canCache)
|
|
30
|
+
cache.set(mod, to);
|
|
17
31
|
return to;
|
|
18
32
|
};
|
|
19
33
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
34
|
+
var __returnValue = (v) => v;
|
|
35
|
+
function __exportSetter(name, newValue) {
|
|
36
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
37
|
+
}
|
|
20
38
|
var __export = (target, all) => {
|
|
21
39
|
for (var name in all)
|
|
22
40
|
__defProp(target, name, {
|
|
23
41
|
get: all[name],
|
|
24
42
|
enumerable: true,
|
|
25
43
|
configurable: true,
|
|
26
|
-
set: (
|
|
44
|
+
set: __exportSetter.bind(all, name)
|
|
27
45
|
});
|
|
28
46
|
};
|
|
29
47
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
@@ -6278,7 +6296,7 @@ var require_formats = __commonJS((exports) => {
|
|
|
6278
6296
|
}
|
|
6279
6297
|
var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
|
|
6280
6298
|
function getTime(strictTimeZone) {
|
|
6281
|
-
return function
|
|
6299
|
+
return function time3(str) {
|
|
6282
6300
|
const matches = TIME.exec(str);
|
|
6283
6301
|
if (!matches)
|
|
6284
6302
|
return false;
|
|
@@ -14190,8 +14208,9 @@ class Config {
|
|
|
14190
14208
|
this.watcher = watch(this.configPath, (event) => {
|
|
14191
14209
|
if (event !== "change")
|
|
14192
14210
|
return;
|
|
14211
|
+
const oldConfig = this.getAll();
|
|
14193
14212
|
this.reload();
|
|
14194
|
-
callback(this.config);
|
|
14213
|
+
callback(oldConfig, this.config);
|
|
14195
14214
|
});
|
|
14196
14215
|
console.error(` Watching config: ${this.configPath}`);
|
|
14197
14216
|
}
|
|
@@ -24784,6 +24803,20 @@ class WebSocketClientTransport {
|
|
|
24784
24803
|
}
|
|
24785
24804
|
|
|
24786
24805
|
// src/connections.ts
|
|
24806
|
+
function parseEnvironmentVariables(env) {
|
|
24807
|
+
if (!env)
|
|
24808
|
+
return;
|
|
24809
|
+
const parsed = {};
|
|
24810
|
+
const processEnv = process.env;
|
|
24811
|
+
for (const [key, value] of Object.entries(env)) {
|
|
24812
|
+
const substitutedValue = value.replace(/\{env:(\w+)\}/g, (_, envVarName) => {
|
|
24813
|
+
return processEnv[envVarName] || "";
|
|
24814
|
+
});
|
|
24815
|
+
parsed[key] = substitutedValue;
|
|
24816
|
+
}
|
|
24817
|
+
return parsed;
|
|
24818
|
+
}
|
|
24819
|
+
|
|
24787
24820
|
class ConnectionManager {
|
|
24788
24821
|
searchEngine;
|
|
24789
24822
|
jobManager;
|
|
@@ -24803,7 +24836,12 @@ class ConnectionManager {
|
|
|
24803
24836
|
const [cmd, ...args] = config2.command || [];
|
|
24804
24837
|
if (!cmd)
|
|
24805
24838
|
throw new Error(`Missing command for ${serverKey}`);
|
|
24806
|
-
const
|
|
24839
|
+
const env = parseEnvironmentVariables(config2.environment);
|
|
24840
|
+
const transport = new StdioClientTransport({
|
|
24841
|
+
command: cmd,
|
|
24842
|
+
args,
|
|
24843
|
+
env
|
|
24844
|
+
});
|
|
24807
24845
|
await this.connectTransport(serverKey, config2, transport);
|
|
24808
24846
|
}
|
|
24809
24847
|
async connectRemote(serverKey, config2) {
|
|
@@ -24866,6 +24904,10 @@ class ConnectionManager {
|
|
|
24866
24904
|
async disconnect(serverKey) {
|
|
24867
24905
|
const client = this.upstreams.get(serverKey);
|
|
24868
24906
|
if (client) {
|
|
24907
|
+
const tools = this.searchEngine.getTools().filter((t) => t.server === serverKey);
|
|
24908
|
+
for (const tool of tools) {
|
|
24909
|
+
this.searchEngine.removeTool(tool.id);
|
|
24910
|
+
}
|
|
24869
24911
|
await client.close();
|
|
24870
24912
|
this.upstreams.delete(serverKey);
|
|
24871
24913
|
}
|
|
@@ -24882,7 +24924,7 @@ class ConnectionManager {
|
|
|
24882
24924
|
// package.json
|
|
24883
24925
|
var package_default = {
|
|
24884
24926
|
name: "@eznix/mcp-gateway",
|
|
24885
|
-
version: "1.
|
|
24927
|
+
version: "1.5.2",
|
|
24886
24928
|
description: "MCP Gateway - Aggregate multiple MCP servers into a single gateway",
|
|
24887
24929
|
type: "module",
|
|
24888
24930
|
bin: {
|
|
@@ -24895,7 +24937,7 @@ var package_default = {
|
|
|
24895
24937
|
"build:docker": "bun build src/docker.ts --target bun --outfile=gateway",
|
|
24896
24938
|
"docker:build": "docker build -t mcp-gateway .",
|
|
24897
24939
|
"docker:run": "docker run -p 3000:3000 -v ./examples/config.json:/home/gateway/.config/mcp-gateway/config.json:ro mcp-gateway",
|
|
24898
|
-
preversion: 'bun run build && git add dist && git commit -m "chore: update build"',
|
|
24940
|
+
preversion: 'bun run build && git add dist && git diff --cached --quiet || git commit -m "chore: update build"',
|
|
24899
24941
|
prepublishOnly: "bun run build",
|
|
24900
24942
|
postversion: "git push && git push --tag"
|
|
24901
24943
|
},
|
|
@@ -26187,7 +26229,7 @@ class MCPGateway {
|
|
|
26187
26229
|
this.connectAll().catch((err) => {
|
|
26188
26230
|
console.error(`Background connection error: ${err.message}`);
|
|
26189
26231
|
});
|
|
26190
|
-
this.config.watch((
|
|
26232
|
+
this.config.watch((oldCfg, newCfg) => this.handleConfigChange(oldCfg, newCfg));
|
|
26191
26233
|
}
|
|
26192
26234
|
async startWithHttp(port = 3000) {
|
|
26193
26235
|
console.error(`MCP Gateway starting (http://localhost:${port})...`);
|
|
@@ -26196,11 +26238,10 @@ class MCPGateway {
|
|
|
26196
26238
|
sessionIdGenerator: undefined
|
|
26197
26239
|
});
|
|
26198
26240
|
await this.server.connect(transport);
|
|
26199
|
-
this.config.watch((
|
|
26241
|
+
this.config.watch((oldCfg, newCfg) => this.handleConfigChange(oldCfg, newCfg));
|
|
26200
26242
|
return transport;
|
|
26201
26243
|
}
|
|
26202
|
-
handleConfigChange(newConfig) {
|
|
26203
|
-
const oldConfig = this.config.getAll();
|
|
26244
|
+
handleConfigChange(oldConfig, newConfig) {
|
|
26204
26245
|
const oldServers = new Set(Object.keys(oldConfig));
|
|
26205
26246
|
const newServers = new Set(Object.keys(newConfig));
|
|
26206
26247
|
const toRemove = [...oldServers].filter((s) => !newServers.has(s));
|
|
@@ -26214,13 +26255,30 @@ class MCPGateway {
|
|
|
26214
26255
|
for (const key of toUpdate) {
|
|
26215
26256
|
const oldC = oldConfig[key];
|
|
26216
26257
|
const newC = newConfig[key];
|
|
26217
|
-
if (oldC &&
|
|
26258
|
+
if (oldC && oldC.enabled !== false && newC && newC.enabled === false) {
|
|
26259
|
+
await this.connections.disconnect(key);
|
|
26260
|
+
console.error(` ${key} disabled`);
|
|
26261
|
+
continue;
|
|
26262
|
+
}
|
|
26263
|
+
if (oldC && oldC.enabled === false && newC && newC.enabled !== false) {
|
|
26218
26264
|
try {
|
|
26219
26265
|
await this.connections.connectWithRetry(key, newC);
|
|
26220
|
-
console.error(` ${key}
|
|
26266
|
+
console.error(` ${key} enabled`);
|
|
26221
26267
|
} catch (e) {
|
|
26222
26268
|
console.error(` ${key} failed: ${e.message}`);
|
|
26223
26269
|
}
|
|
26270
|
+
continue;
|
|
26271
|
+
}
|
|
26272
|
+
if (oldC && newC && oldC.enabled !== false && newC.enabled !== false) {
|
|
26273
|
+
if (JSON.stringify(oldC) !== JSON.stringify(newC)) {
|
|
26274
|
+
await this.connections.disconnect(key);
|
|
26275
|
+
try {
|
|
26276
|
+
await this.connections.connectWithRetry(key, newC);
|
|
26277
|
+
console.error(` ${key} reconnected (config changed)`);
|
|
26278
|
+
} catch (e) {
|
|
26279
|
+
console.error(` ${key} failed: ${e.message}`);
|
|
26280
|
+
}
|
|
26281
|
+
}
|
|
26224
26282
|
}
|
|
26225
26283
|
}
|
|
26226
26284
|
for (const key of toAdd) {
|
package/examples/config.json
CHANGED
|
@@ -6,5 +6,24 @@
|
|
|
6
6
|
"playwright": {
|
|
7
7
|
"type": "local",
|
|
8
8
|
"command": ["bunx", "@playwright/mcp@latest"]
|
|
9
|
+
},
|
|
10
|
+
"JupyterLab": {
|
|
11
|
+
"type": "local",
|
|
12
|
+
"command": ["uvx", "jupyter-mcp-server@latest"],
|
|
13
|
+
"environment": {
|
|
14
|
+
"JUPYTER_URL": "http://localhost:{env:JUPYTER_PORT}/",
|
|
15
|
+
"JUPYTER_TOKEN": "{env:JUPYTER_TOKEN}",
|
|
16
|
+
"ALLOW_IMG_OUTPUT": "true"
|
|
17
|
+
},
|
|
18
|
+
"enabled": true
|
|
19
|
+
},
|
|
20
|
+
"custom-server": {
|
|
21
|
+
"type": "local",
|
|
22
|
+
"command": ["python", "scripts/server.py"],
|
|
23
|
+
"environment": {
|
|
24
|
+
"API_KEY": "{env:API_KEY}",
|
|
25
|
+
"DEBUG": "true",
|
|
26
|
+
"DATABASE_URL": "postgresql://{env:DB_USER}:{env:DB_PASS}@localhost:5432/mydb"
|
|
27
|
+
}
|
|
9
28
|
}
|
|
10
29
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eznix/mcp-gateway",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "MCP Gateway - Aggregate multiple MCP servers into a single gateway",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build:docker": "bun build src/docker.ts --target bun --outfile=gateway",
|
|
14
14
|
"docker:build": "docker build -t mcp-gateway .",
|
|
15
15
|
"docker:run": "docker run -p 3000:3000 -v ./examples/config.json:/home/gateway/.config/mcp-gateway/config.json:ro mcp-gateway",
|
|
16
|
-
"preversion": "bun run build && git add dist && git commit -m \"chore: update build\"",
|
|
16
|
+
"preversion": "bun run build && git add dist && git diff --cached --quiet || git commit -m \"chore: update build\"",
|
|
17
17
|
"prepublishOnly": "bun run build",
|
|
18
18
|
"postversion": "git push && git push --tag"
|
|
19
19
|
},
|
package/src/config.ts
CHANGED
|
@@ -37,12 +37,13 @@ export class Config {
|
|
|
37
37
|
return this.config;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
watch(callback: (
|
|
40
|
+
watch(callback: (oldConfig: GatewayConfig, newConfig: GatewayConfig) => void): void {
|
|
41
41
|
if (this.watcher) return;
|
|
42
42
|
this.watcher = watch(this.configPath, (event: string) => {
|
|
43
43
|
if (event !== "change") return;
|
|
44
|
+
const oldConfig = this.getAll(); // snapshot before reload
|
|
44
45
|
this.reload();
|
|
45
|
-
callback(this.config);
|
|
46
|
+
callback(oldConfig, this.config);
|
|
46
47
|
});
|
|
47
48
|
console.error(` Watching config: ${this.configPath}`);
|
|
48
49
|
}
|
package/src/connections.ts
CHANGED
|
@@ -6,6 +6,28 @@ import type { UpstreamConfig, ToolCatalogEntry } from "./types.js";
|
|
|
6
6
|
import { SearchEngine } from "./search.js";
|
|
7
7
|
import { JobManager } from "./jobs.js";
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Parse environment variables with {env:VAR_NAME} substitution.
|
|
11
|
+
* Replaces {env:VAR} with the corresponding value from process.env.
|
|
12
|
+
* If the environment variable is not set, the placeholder is replaced with an empty string.
|
|
13
|
+
*/
|
|
14
|
+
export function parseEnvironmentVariables(env?: Record<string, string>): Record<string, string> | undefined {
|
|
15
|
+
if (!env) return undefined;
|
|
16
|
+
|
|
17
|
+
const parsed: Record<string, string> = {};
|
|
18
|
+
const processEnv = process.env as Record<string, string>;
|
|
19
|
+
|
|
20
|
+
for (const [key, value] of Object.entries(env)) {
|
|
21
|
+
// Replace all {env:VAR_NAME} patterns with actual environment variable values
|
|
22
|
+
const substitutedValue = value.replace(/\{env:(\w+)\}/g, (_, envVarName: string) => {
|
|
23
|
+
return processEnv[envVarName] || "";
|
|
24
|
+
});
|
|
25
|
+
parsed[key] = substitutedValue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
9
31
|
export class ConnectionManager {
|
|
10
32
|
private upstreams = new Map<string, Client>();
|
|
11
33
|
|
|
@@ -26,7 +48,14 @@ export class ConnectionManager {
|
|
|
26
48
|
const [cmd, ...args] = config.command || [];
|
|
27
49
|
if (!cmd) throw new Error(`Missing command for ${serverKey}`);
|
|
28
50
|
|
|
29
|
-
|
|
51
|
+
// Parse environment variables with {env:VAR_NAME} substitution
|
|
52
|
+
const env = parseEnvironmentVariables(config.environment);
|
|
53
|
+
|
|
54
|
+
const transport = new StdioClientTransport({
|
|
55
|
+
command: cmd,
|
|
56
|
+
args,
|
|
57
|
+
env
|
|
58
|
+
});
|
|
30
59
|
await this.connectTransport(serverKey, config, transport);
|
|
31
60
|
}
|
|
32
61
|
|
|
@@ -99,6 +128,12 @@ export class ConnectionManager {
|
|
|
99
128
|
async disconnect(serverKey: string): Promise<void> {
|
|
100
129
|
const client = this.upstreams.get(serverKey);
|
|
101
130
|
if (client) {
|
|
131
|
+
// Remove all tools for this server from the search index
|
|
132
|
+
const tools = this.searchEngine.getTools().filter((t) => t.server === serverKey);
|
|
133
|
+
for (const tool of tools) {
|
|
134
|
+
this.searchEngine.removeTool(tool.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
102
137
|
await client.close();
|
|
103
138
|
this.upstreams.delete(serverKey);
|
|
104
139
|
}
|
package/src/gateway.ts
CHANGED
|
@@ -50,7 +50,7 @@ export class MCPGateway {
|
|
|
50
50
|
console.error(`Background connection error: ${err.message}`);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
-
this.config.watch((
|
|
53
|
+
this.config.watch((oldCfg, newCfg) => this.handleConfigChange(oldCfg, newCfg));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async startWithHttp(port: number = 3000): Promise<StreamableHTTPServerTransport> {
|
|
@@ -61,13 +61,12 @@ export class MCPGateway {
|
|
|
61
61
|
sessionIdGenerator: undefined,
|
|
62
62
|
});
|
|
63
63
|
await this.server.connect(transport);
|
|
64
|
-
this.config.watch((
|
|
64
|
+
this.config.watch((oldCfg, newCfg) => this.handleConfigChange(oldCfg, newCfg));
|
|
65
65
|
|
|
66
66
|
return transport;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
private handleConfigChange(newConfig: GatewayConfig): void {
|
|
70
|
-
const oldConfig = this.config.getAll();
|
|
69
|
+
private handleConfigChange(oldConfig: GatewayConfig, newConfig: GatewayConfig): void {
|
|
71
70
|
const oldServers = new Set(Object.keys(oldConfig));
|
|
72
71
|
const newServers = new Set(Object.keys(newConfig));
|
|
73
72
|
|
|
@@ -84,8 +83,37 @@ export class MCPGateway {
|
|
|
84
83
|
for (const key of toUpdate) {
|
|
85
84
|
const oldC = oldConfig[key];
|
|
86
85
|
const newC = newConfig[key];
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
|
|
87
|
+
// Handle disabling a server
|
|
88
|
+
if (oldC && oldC.enabled !== false && newC && newC.enabled === false) {
|
|
89
|
+
await this.connections.disconnect(key);
|
|
90
|
+
console.error(` ${key} disabled`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle enabling a previously disabled server
|
|
95
|
+
if (oldC && oldC.enabled === false && newC && newC.enabled !== false) {
|
|
96
|
+
try {
|
|
97
|
+
await this.connections.connectWithRetry(key, newC);
|
|
98
|
+
console.error(` ${key} enabled`);
|
|
99
|
+
} catch (e: any) {
|
|
100
|
+
console.error(` ${key} failed: ${e.message}`);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Handle config changes for enabled servers (reconnect)
|
|
106
|
+
if (oldC && newC && oldC.enabled !== false && newC.enabled !== false) {
|
|
107
|
+
// Deep compare to detect any config changes
|
|
108
|
+
if (JSON.stringify(oldC) !== JSON.stringify(newC)) {
|
|
109
|
+
await this.connections.disconnect(key);
|
|
110
|
+
try {
|
|
111
|
+
await this.connections.connectWithRetry(key, newC);
|
|
112
|
+
console.error(` ${key} reconnected (config changed)`);
|
|
113
|
+
} catch (e: any) {
|
|
114
|
+
console.error(` ${key} failed: ${e.message}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
89
117
|
}
|
|
90
118
|
}
|
|
91
119
|
|
package/src/types.ts
CHANGED
|
@@ -4,9 +4,10 @@ export interface UpstreamConfig {
|
|
|
4
4
|
url?: string;
|
|
5
5
|
transport?: "streamable_http" | "websocket";
|
|
6
6
|
endpoint?: string;
|
|
7
|
+
environment?: Record<string, string>; // Environment variables with support for {env:VAR_NAME} substitution
|
|
7
8
|
enabled?: boolean;
|
|
8
|
-
lazy?: boolean;
|
|
9
|
-
idleTimeout?: number;
|
|
9
|
+
lazy?: boolean; // if true, only connect on first request
|
|
10
|
+
idleTimeout?: number; // milliseconds before sleeping (default: 2hrs)
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
export interface GatewayConfig {
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { test, expect, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { parseEnvironmentVariables } from "../src/connections.js";
|
|
3
|
+
|
|
4
|
+
// Store original environment variables
|
|
5
|
+
const originalEnv: Record<string, string | undefined> = {};
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
// Save original environment variables
|
|
9
|
+
for (const key of ["TEST_VAR_1", "TEST_VAR_2", "MISSING_VAR", "JUPYTER_TOKEN", "JUPYTER_PORT"]) {
|
|
10
|
+
originalEnv[key] = process.env[key];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Set test environment variables
|
|
14
|
+
process.env.TEST_VAR_1 = "test-value-1";
|
|
15
|
+
process.env.TEST_VAR_2 = "test-value-2";
|
|
16
|
+
process.env.JUPYTER_TOKEN = "my-secret-token";
|
|
17
|
+
process.env.JUPYTER_PORT = "8888";
|
|
18
|
+
|
|
19
|
+
// Ensure MISSING_VAR is not set
|
|
20
|
+
delete process.env.MISSING_VAR;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
// Restore original environment variables
|
|
25
|
+
for (const key of ["TEST_VAR_1", "TEST_VAR_2", "MISSING_VAR", "JUPYTER_TOKEN", "JUPYTER_PORT"]) {
|
|
26
|
+
if (originalEnv[key] !== undefined) {
|
|
27
|
+
process.env[key] = originalEnv[key];
|
|
28
|
+
} else {
|
|
29
|
+
delete process.env[key];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("parseEnvironmentVariables returns undefined when env is undefined", () => {
|
|
35
|
+
const result = parseEnvironmentVariables(undefined);
|
|
36
|
+
expect(result).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("parseEnvironmentVariables returns undefined when env is empty object", () => {
|
|
40
|
+
const result = parseEnvironmentVariables({});
|
|
41
|
+
expect(result).toEqual({});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("parseEnvironmentVariables preserves values without substitution", () => {
|
|
45
|
+
const input = {
|
|
46
|
+
STATIC_VALUE: "hello world",
|
|
47
|
+
NUMBER_VALUE: "12345",
|
|
48
|
+
URL_VALUE: "https://example.com/api"
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const result = parseEnvironmentVariables(input);
|
|
52
|
+
expect(result).toEqual(input);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("parseEnvironmentVariables substitutes existing environment variables", () => {
|
|
56
|
+
const input = {
|
|
57
|
+
STATIC: "static",
|
|
58
|
+
SUBSTITUTED: "{env:TEST_VAR_1}",
|
|
59
|
+
ANOTHER_SUB: "{env:TEST_VAR_2}"
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const expected = {
|
|
63
|
+
STATIC: "static",
|
|
64
|
+
SUBSTITUTED: "test-value-1",
|
|
65
|
+
ANOTHER_SUB: "test-value-2"
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const result = parseEnvironmentVariables(input);
|
|
69
|
+
expect(result).toEqual(expected);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("parseEnvironmentVariables substitutes multiple variables in same value", () => {
|
|
73
|
+
const input = {
|
|
74
|
+
COMBINED: "prefix-{env:TEST_VAR_1}-middle-{env:TEST_VAR_2}-suffix"
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const expected = {
|
|
78
|
+
COMBINED: "prefix-test-value-1-middle-test-value-2-suffix"
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const result = parseEnvironmentVariables(input);
|
|
82
|
+
expect(result).toEqual(expected);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("parseEnvironmentVariables replaces missing variables with empty string", () => {
|
|
86
|
+
const input = {
|
|
87
|
+
PRESENT: "{env:TEST_VAR_1}",
|
|
88
|
+
MISSING: "{env:MISSING_VAR}",
|
|
89
|
+
AFTER_MISSING: "value-after-{env:MISSING_VAR}"
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const expected = {
|
|
93
|
+
PRESENT: "test-value-1",
|
|
94
|
+
MISSING: "",
|
|
95
|
+
AFTER_MISSING: "value-after-"
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const result = parseEnvironmentVariables(input);
|
|
99
|
+
expect(result).toEqual(expected);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("parseEnvironmentVariables works with JupyterLab example from README", () => {
|
|
103
|
+
const input = {
|
|
104
|
+
JUPYTER_URL: "http://localhost:{env:JUPYTER_PORT}/",
|
|
105
|
+
JUPYTER_TOKEN: "{env:JUPYTER_TOKEN}",
|
|
106
|
+
ALLOW_IMG_OUTPUT: "true"
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const expected = {
|
|
110
|
+
JUPYTER_URL: "http://localhost:8888/",
|
|
111
|
+
JUPYTER_TOKEN: "my-secret-token",
|
|
112
|
+
ALLOW_IMG_OUTPUT: "true"
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const result = parseEnvironmentVariables(input);
|
|
116
|
+
expect(result).toEqual(expected);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("parseEnvironmentVariables handles database URL with multiple substitutions", () => {
|
|
120
|
+
const input = {
|
|
121
|
+
DATABASE_URL: "postgresql://{env:DB_USER}:{env:DB_PASS}@localhost:5432/mydb"
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Set up the environment variables for this test
|
|
125
|
+
const originalDbUser = process.env.DB_USER;
|
|
126
|
+
const originalDbPass = process.env.DB_PASS;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
process.env.DB_USER = "admin";
|
|
130
|
+
process.env.DB_PASS = "secret123";
|
|
131
|
+
|
|
132
|
+
const expected = {
|
|
133
|
+
DATABASE_URL: "postgresql://admin:secret123@localhost:5432/mydb"
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const result = parseEnvironmentVariables(input);
|
|
137
|
+
expect(result).toEqual(expected);
|
|
138
|
+
} finally {
|
|
139
|
+
// Restore
|
|
140
|
+
if (originalDbUser !== undefined) {
|
|
141
|
+
process.env.DB_USER = originalDbUser;
|
|
142
|
+
} else {
|
|
143
|
+
delete process.env.DB_USER;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (originalDbPass !== undefined) {
|
|
147
|
+
process.env.DB_PASS = originalDbPass;
|
|
148
|
+
} else {
|
|
149
|
+
delete process.env.DB_PASS;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("parseEnvironmentVariables handles empty values", () => {
|
|
155
|
+
const input = {
|
|
156
|
+
EMPTY_STRING: "",
|
|
157
|
+
SPACES: " "
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const result = parseEnvironmentVariables(input);
|
|
161
|
+
expect(result).toEqual(input);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("parseEnvironmentVariables is case-sensitive for variable names", () => {
|
|
165
|
+
const input = {
|
|
166
|
+
LOWERCASE: "{env:test_var_1}", // Note: different case than TEST_VAR_1
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const expected = {
|
|
170
|
+
LOWERCASE: "" // Should be empty because test_var_1 is not set
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const result = parseEnvironmentVariables(input);
|
|
174
|
+
expect(result).toEqual(expected);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("parseEnvironmentVariables handles complex patterns", () => {
|
|
178
|
+
const input = {
|
|
179
|
+
NESTED: "prefix-{env:TEST_VAR_1}-middle-{env:TEST_VAR_2}-suffix-{env:JUPYTER_TOKEN}",
|
|
180
|
+
WITH_NUMBERS: "server-{env:JUPYTER_PORT}.example.com",
|
|
181
|
+
MIXED: "{env:TEST_VAR_1}-{env:MISSING}-{env:TEST_VAR_2}"
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const expected = {
|
|
185
|
+
NESTED: "prefix-test-value-1-middle-test-value-2-suffix-my-secret-token",
|
|
186
|
+
WITH_NUMBERS: "server-8888.example.com",
|
|
187
|
+
MIXED: "test-value-1--test-value-2"
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = parseEnvironmentVariables(input);
|
|
191
|
+
expect(result).toEqual(expected);
|
|
192
|
+
});
|