@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 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: () => mod[key],
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: (newValue) => all[name] = () => newValue
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 time(str) {
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 transport = new StdioClientTransport({ command: cmd, args });
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.3.6",
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((cfg) => this.handleConfigChange(cfg));
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((cfg) => this.handleConfigChange(cfg));
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 && newC && oldC.enabled === false && newC.enabled !== false) {
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} connected`);
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) {
@@ -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.6",
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: (config: GatewayConfig) => void): void {
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
  }
@@ -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
- const transport = new StdioClientTransport({ command: cmd, args });
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((cfg) => this.handleConfigChange(cfg));
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((cfg) => this.handleConfigChange(cfg));
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
- if (oldC && newC && oldC.enabled === false && newC.enabled !== false) {
88
- try { await this.connections.connectWithRetry(key, newC); console.error(` ${key} connected`); } catch (e: any) { console.error(` ${key} failed: ${e.message}`); }
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; // if true, only connect on first request
9
- idleTimeout?: number; // milliseconds before sleeping (default: 2hrs)
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
+ });