@eznix/mcp-gateway 1.3.6 → 1.4.0
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 +22 -3
- package/examples/config.json +19 -0
- package/package.json +2 -2
- package/src/connections.ts +30 -1
- 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
|
@@ -24784,6 +24784,20 @@ class WebSocketClientTransport {
|
|
|
24784
24784
|
}
|
|
24785
24785
|
|
|
24786
24786
|
// src/connections.ts
|
|
24787
|
+
function parseEnvironmentVariables(env) {
|
|
24788
|
+
if (!env)
|
|
24789
|
+
return;
|
|
24790
|
+
const parsed = {};
|
|
24791
|
+
const processEnv = process.env;
|
|
24792
|
+
for (const [key, value] of Object.entries(env)) {
|
|
24793
|
+
const substitutedValue = value.replace(/\{env:(\w+)\}/g, (_, envVarName) => {
|
|
24794
|
+
return processEnv[envVarName] || "";
|
|
24795
|
+
});
|
|
24796
|
+
parsed[key] = substitutedValue;
|
|
24797
|
+
}
|
|
24798
|
+
return parsed;
|
|
24799
|
+
}
|
|
24800
|
+
|
|
24787
24801
|
class ConnectionManager {
|
|
24788
24802
|
searchEngine;
|
|
24789
24803
|
jobManager;
|
|
@@ -24803,7 +24817,12 @@ class ConnectionManager {
|
|
|
24803
24817
|
const [cmd, ...args] = config2.command || [];
|
|
24804
24818
|
if (!cmd)
|
|
24805
24819
|
throw new Error(`Missing command for ${serverKey}`);
|
|
24806
|
-
const
|
|
24820
|
+
const env = parseEnvironmentVariables(config2.environment);
|
|
24821
|
+
const transport = new StdioClientTransport({
|
|
24822
|
+
command: cmd,
|
|
24823
|
+
args,
|
|
24824
|
+
env
|
|
24825
|
+
});
|
|
24807
24826
|
await this.connectTransport(serverKey, config2, transport);
|
|
24808
24827
|
}
|
|
24809
24828
|
async connectRemote(serverKey, config2) {
|
|
@@ -24882,7 +24901,7 @@ class ConnectionManager {
|
|
|
24882
24901
|
// package.json
|
|
24883
24902
|
var package_default = {
|
|
24884
24903
|
name: "@eznix/mcp-gateway",
|
|
24885
|
-
version: "1.
|
|
24904
|
+
version: "1.4.0",
|
|
24886
24905
|
description: "MCP Gateway - Aggregate multiple MCP servers into a single gateway",
|
|
24887
24906
|
type: "module",
|
|
24888
24907
|
bin: {
|
|
@@ -24895,7 +24914,7 @@ var package_default = {
|
|
|
24895
24914
|
"build:docker": "bun build src/docker.ts --target bun --outfile=gateway",
|
|
24896
24915
|
"docker:build": "docker build -t mcp-gateway .",
|
|
24897
24916
|
"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"',
|
|
24917
|
+
preversion: 'bun run build && git add dist && git diff --cached --quiet || git commit -m "chore: update build"',
|
|
24899
24918
|
prepublishOnly: "bun run build",
|
|
24900
24919
|
postversion: "git push && git push --tag"
|
|
24901
24920
|
},
|
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.4.0",
|
|
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/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
|
|
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
|
+
});
|