@aiwerk/mcp-bridge 1.0.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.
Files changed (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -0
  3. package/bin/mcp-bridge.js +9 -0
  4. package/bin/mcp-bridge.ts +335 -0
  5. package/package.json +42 -0
  6. package/scripts/install-server.ps1 +300 -0
  7. package/scripts/install-server.sh +357 -0
  8. package/servers/apify/README.md +40 -0
  9. package/servers/apify/config.json +13 -0
  10. package/servers/apify/env_vars +1 -0
  11. package/servers/apify/install.ps1 +3 -0
  12. package/servers/apify/install.sh +4 -0
  13. package/servers/candidates.md +13 -0
  14. package/servers/github/README.md +40 -0
  15. package/servers/github/config.json +21 -0
  16. package/servers/github/env_vars +1 -0
  17. package/servers/github/install.ps1 +3 -0
  18. package/servers/github/install.sh +4 -0
  19. package/servers/google-maps/README.md +40 -0
  20. package/servers/google-maps/config.json +17 -0
  21. package/servers/google-maps/env_vars +1 -0
  22. package/servers/google-maps/install.ps1 +3 -0
  23. package/servers/google-maps/install.sh +4 -0
  24. package/servers/hetzner/README.md +41 -0
  25. package/servers/hetzner/config.json +16 -0
  26. package/servers/hetzner/env_vars +1 -0
  27. package/servers/hetzner/install.ps1 +3 -0
  28. package/servers/hetzner/install.sh +4 -0
  29. package/servers/hostinger/README.md +40 -0
  30. package/servers/hostinger/config.json +17 -0
  31. package/servers/hostinger/env_vars +1 -0
  32. package/servers/hostinger/install.ps1 +3 -0
  33. package/servers/hostinger/install.sh +4 -0
  34. package/servers/index.json +125 -0
  35. package/servers/linear/README.md +40 -0
  36. package/servers/linear/config.json +16 -0
  37. package/servers/linear/env_vars +1 -0
  38. package/servers/linear/install.ps1 +3 -0
  39. package/servers/linear/install.sh +4 -0
  40. package/servers/miro/README.md +40 -0
  41. package/servers/miro/config.json +19 -0
  42. package/servers/miro/env_vars +1 -0
  43. package/servers/miro/install.ps1 +3 -0
  44. package/servers/miro/install.sh +4 -0
  45. package/servers/notion/README.md +42 -0
  46. package/servers/notion/config.json +17 -0
  47. package/servers/notion/env_vars +1 -0
  48. package/servers/notion/install.ps1 +3 -0
  49. package/servers/notion/install.sh +4 -0
  50. package/servers/stripe/README.md +40 -0
  51. package/servers/stripe/config.json +19 -0
  52. package/servers/stripe/env_vars +1 -0
  53. package/servers/stripe/install.ps1 +3 -0
  54. package/servers/stripe/install.sh +4 -0
  55. package/servers/tavily/README.md +40 -0
  56. package/servers/tavily/config.json +17 -0
  57. package/servers/tavily/env_vars +1 -0
  58. package/servers/tavily/install.ps1 +3 -0
  59. package/servers/tavily/install.sh +4 -0
  60. package/servers/todoist/README.md +40 -0
  61. package/servers/todoist/config.json +17 -0
  62. package/servers/todoist/env_vars +1 -0
  63. package/servers/todoist/install.ps1 +3 -0
  64. package/servers/todoist/install.sh +4 -0
  65. package/servers/wise/README.md +41 -0
  66. package/servers/wise/config.json +16 -0
  67. package/servers/wise/env_vars +1 -0
  68. package/servers/wise/install.ps1 +3 -0
  69. package/servers/wise/install.sh +4 -0
  70. package/src/config.ts +168 -0
  71. package/src/index.ts +44 -0
  72. package/src/mcp-router.ts +366 -0
  73. package/src/protocol.ts +69 -0
  74. package/src/schema-convert.ts +178 -0
  75. package/src/standalone-server.ts +385 -0
  76. package/src/tool-naming.ts +51 -0
  77. package/src/transport-base.ts +199 -0
  78. package/src/transport-sse.ts +230 -0
  79. package/src/transport-stdio.ts +312 -0
  80. package/src/transport-streamable-http.ts +188 -0
  81. package/src/types.ts +88 -0
  82. package/src/update-checker.ts +155 -0
  83. package/tests/collision.test.ts +60 -0
  84. package/tests/env-resolve.test.ts +68 -0
  85. package/tests/mcp-router.test.ts +301 -0
  86. package/tests/schema-convert.test.ts +70 -0
  87. package/tests/transport-base.test.ts +214 -0
  88. package/tsconfig.json +15 -0
@@ -0,0 +1,188 @@
1
+ import { McpRequest, McpResponse, McpServerConfig, nextRequestId } from "./types.js";
2
+ import { BaseTransport, resolveEnvRecord, warnIfNonTlsRemoteUrl } from "./transport-base.js";
3
+
4
+ export class StreamableHttpTransport extends BaseTransport {
5
+ private sessionId?: string;
6
+
7
+ protected get transportName(): string { return "streamable-http"; }
8
+
9
+ async connect(): Promise<void> {
10
+ if (!this.config.url) {
11
+ throw new Error("Streamable HTTP transport requires URL");
12
+ }
13
+
14
+ warnIfNonTlsRemoteUrl(this.config.url, this.logger);
15
+ // Validate that all header env vars resolve (fail fast)
16
+ resolveEnvRecord(this.config.headers || {}, "header");
17
+ await this.probeServer();
18
+
19
+ this.connected = true;
20
+ this.backoffDelay = this.clientConfig.reconnectIntervalMs || 30000;
21
+ this.logger.info(`[mcp-bridge] Streamable HTTP transport ready for ${this.config.url}`);
22
+ }
23
+
24
+ async sendRequest(request: McpRequest): Promise<McpResponse> {
25
+ if (!this.connected || !this.config.url) {
26
+ throw new Error("Streamable HTTP transport not connected");
27
+ }
28
+
29
+ const id = nextRequestId();
30
+ const requestWithId = { ...request, id };
31
+
32
+ return new Promise((resolve, reject) => {
33
+ const requestTimeout = this.clientConfig.requestTimeoutMs || 60000;
34
+ const timeout = setTimeout(() => {
35
+ this.pendingRequests.delete(id);
36
+ reject(new Error(`Request timeout after ${requestTimeout}ms`));
37
+ }, requestTimeout);
38
+
39
+ this.pendingRequests.set(id, { resolve, reject, timeout });
40
+
41
+ const headers = resolveEnvRecord({
42
+ "Accept": "application/json, text/event-stream",
43
+ ...this.config.headers,
44
+ "Content-Type": "application/json"
45
+ }, "header");
46
+
47
+ if (this.sessionId) {
48
+ headers["mcp-session-id"] = this.sessionId;
49
+ }
50
+
51
+ fetch(this.config.url!, {
52
+ method: "POST",
53
+ headers,
54
+ body: JSON.stringify(requestWithId)
55
+ })
56
+ .then(async response => {
57
+ const responseSessionId = response.headers.get("mcp-session-id");
58
+ if (responseSessionId) {
59
+ this.sessionId = responseSessionId;
60
+ }
61
+
62
+ if (!response.ok) {
63
+ clearTimeout(timeout);
64
+ this.pendingRequests.delete(id);
65
+ reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
66
+ return;
67
+ }
68
+
69
+ try {
70
+ const contentType = response.headers.get("content-type") || "";
71
+ let jsonResponse: any;
72
+
73
+ if (contentType.includes("text/event-stream")) {
74
+ const text = await response.text();
75
+ const dataLines = text.split('\n')
76
+ .filter((line: string) => line.startsWith('data:'))
77
+ .map((line: string) => line.substring(5).trim());
78
+ if (dataLines.length > 0) {
79
+ jsonResponse = JSON.parse(dataLines[dataLines.length - 1]);
80
+ } else {
81
+ throw new Error("No data lines in SSE response");
82
+ }
83
+ } else {
84
+ jsonResponse = await response.json();
85
+ }
86
+
87
+ this.handleMessage(jsonResponse);
88
+ } catch (error) {
89
+ clearTimeout(timeout);
90
+ this.pendingRequests.delete(id);
91
+ reject(new Error("Failed to parse response: " + (error instanceof Error ? error.message : String(error))));
92
+ }
93
+ })
94
+ .catch(error => {
95
+ clearTimeout(timeout);
96
+ this.pendingRequests.delete(id);
97
+
98
+ if (error.name === 'TypeError' && error.message.includes('fetch')) {
99
+ this.logger.error("Connection error, scheduling reconnect:", error.message);
100
+ this.scheduleReconnect();
101
+ }
102
+
103
+ reject(error);
104
+ });
105
+ });
106
+ }
107
+
108
+ async sendNotification(notification: any): Promise<void> {
109
+ if (!this.connected || !this.config.url) {
110
+ throw new Error("Streamable HTTP transport not connected");
111
+ }
112
+
113
+ const headers = resolveEnvRecord({
114
+ "Accept": "application/json, text/event-stream",
115
+ ...this.config.headers,
116
+ "Content-Type": "application/json"
117
+ }, "header");
118
+
119
+ if (this.sessionId) {
120
+ headers["mcp-session-id"] = this.sessionId;
121
+ }
122
+
123
+ try {
124
+ const response = await fetch(this.config.url, {
125
+ method: "POST",
126
+ headers,
127
+ body: JSON.stringify(notification)
128
+ });
129
+
130
+ const responseSessionId = response.headers.get("mcp-session-id");
131
+ if (responseSessionId) {
132
+ this.sessionId = responseSessionId;
133
+ }
134
+
135
+ if (!response.ok && response.status >= 500) {
136
+ throw new Error(`Server error: HTTP ${response.status}`);
137
+ }
138
+ } catch (error) {
139
+ if (error instanceof Error && error.name === 'TypeError' && error.message.includes('fetch')) {
140
+ this.logger.error("Connection error during notification, scheduling reconnect:", error.message);
141
+ this.scheduleReconnect();
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ private async probeServer(): Promise<void> {
148
+ if (!this.config.url) return;
149
+
150
+ try {
151
+ const optionsResponse = await fetch(this.config.url, { method: "OPTIONS" });
152
+ if (optionsResponse.ok) return;
153
+
154
+ const headers = resolveEnvRecord(this.config.headers || {}, "header");
155
+ const headResponse = await fetch(this.config.url, { method: "HEAD", headers });
156
+ if (!headResponse.ok) {
157
+ this.logger.warn(`[mcp-bridge] Streamable HTTP server probe: OPTIONS ${optionsResponse.status}, HEAD ${headResponse.status} (non-blocking, connection continues)`);
158
+ }
159
+ } catch (error: any) {
160
+ this.logger.warn(`[mcp-bridge] Streamable HTTP server probe failed (non-blocking): ${error?.message || error}`);
161
+ }
162
+ }
163
+
164
+ async disconnect(): Promise<void> {
165
+ this.connected = false;
166
+ this.cleanupReconnectTimer();
167
+
168
+ // Send DELETE request if we have a session to clean up
169
+ if (this.sessionId && this.config.url) {
170
+ try {
171
+ const headers = resolveEnvRecord(this.config.headers || {}, "header");
172
+ headers["mcp-session-id"] = this.sessionId;
173
+
174
+ await fetch(this.config.url, {
175
+ method: "DELETE",
176
+ headers
177
+ });
178
+
179
+ this.sessionId = undefined;
180
+ this.logger.info("Streamable HTTP session cleaned up");
181
+ } catch (error) {
182
+ this.logger.warn("Failed to clean up session on disconnect:", error);
183
+ }
184
+ }
185
+
186
+ this.rejectAllPending("Connection closed");
187
+ }
188
+ }
package/src/types.ts ADDED
@@ -0,0 +1,88 @@
1
+ export interface Logger {
2
+ info: (...args: unknown[]) => void;
3
+ warn: (...args: unknown[]) => void;
4
+ error: (...args: unknown[]) => void;
5
+ debug: (...args: unknown[]) => void;
6
+ }
7
+
8
+ export interface McpServerConfig {
9
+ transport: "sse" | "stdio" | "streamable-http";
10
+ /** Human-readable description for router tool description generation */
11
+ description?: string;
12
+ // SSE transport
13
+ url?: string;
14
+ headers?: Record<string, string>;
15
+ // Stdio transport
16
+ command?: string;
17
+ args?: string[];
18
+ env?: Record<string, string>;
19
+ // Stdio framing override (default: auto-detect from first message)
20
+ framing?: "auto" | "lsp" | "newline";
21
+ }
22
+
23
+ export interface McpClientConfig {
24
+ servers: Record<string, McpServerConfig>;
25
+ mode?: "direct" | "router";
26
+ toolPrefix?: boolean | "auto";
27
+ reconnectIntervalMs?: number;
28
+ connectionTimeoutMs?: number;
29
+ requestTimeoutMs?: number;
30
+ routerIdleTimeoutMs?: number;
31
+ routerMaxConcurrent?: number;
32
+ }
33
+
34
+ export interface McpTool {
35
+ name: string;
36
+ description: string;
37
+ inputSchema: any; // JSON Schema
38
+ }
39
+
40
+ export interface McpRequest {
41
+ jsonrpc: "2.0";
42
+ id?: number;
43
+ method: string;
44
+ params?: any;
45
+ }
46
+
47
+ let globalRequestId = 1;
48
+
49
+ export function nextRequestId(): number {
50
+ return globalRequestId++;
51
+ }
52
+
53
+ export interface McpResponse {
54
+ jsonrpc: "2.0";
55
+ id: number;
56
+ result?: any;
57
+ error?: {
58
+ code: number;
59
+ message: string;
60
+ data?: any;
61
+ };
62
+ }
63
+
64
+ export interface McpTransport {
65
+ connect(): Promise<void>;
66
+ disconnect(): Promise<void>;
67
+ sendRequest(request: McpRequest): Promise<McpResponse>;
68
+ sendNotification(notification: any): Promise<void>;
69
+ isConnected(): boolean;
70
+ }
71
+
72
+ export interface McpServerConnection {
73
+ name: string;
74
+ transport: McpTransport;
75
+ tools: McpTool[];
76
+ isInitialized: boolean;
77
+ registeredToolNames: string[];
78
+ }
79
+
80
+ /** Bridge-level config loaded from ~/.mcp-bridge/config.json */
81
+ export interface BridgeConfig extends McpClientConfig {
82
+ http?: {
83
+ auth?: {
84
+ type: "bearer";
85
+ token: string;
86
+ };
87
+ };
88
+ }
@@ -0,0 +1,155 @@
1
+ import { execSync, exec as execCb } from "child_process";
2
+ import { Logger } from "./types.js";
3
+ import { PACKAGE_VERSION } from "./protocol.js";
4
+
5
+ export interface UpdateInfo {
6
+ currentVersion: string;
7
+ latestVersion: string;
8
+ updateAvailable: boolean;
9
+ updateCommand: string;
10
+ }
11
+
12
+ const PACKAGE_NAME = "@aiwerk/mcp-bridge";
13
+
14
+ let cachedUpdateInfo: UpdateInfo | null = null;
15
+ let noticeDelivered = false;
16
+
17
+ /**
18
+ * Check npm registry for a newer version. Non-blocking, best-effort.
19
+ * Caches result for the lifetime of the process.
20
+ */
21
+ export async function checkForUpdate(logger: Logger): Promise<UpdateInfo> {
22
+ if (cachedUpdateInfo) return cachedUpdateInfo;
23
+
24
+ const current = PACKAGE_VERSION;
25
+ const updateCmd = `npm update -g ${PACKAGE_NAME}`;
26
+
27
+ try {
28
+ const latest = await npmViewVersion(logger);
29
+ const updateAvailable = latest !== current && isNewer(latest, current);
30
+
31
+ cachedUpdateInfo = {
32
+ currentVersion: current,
33
+ latestVersion: latest,
34
+ updateAvailable,
35
+ updateCommand: updateCmd,
36
+ };
37
+
38
+ if (updateAvailable) {
39
+ logger.info(`[mcp-bridge] Update available: ${current} → ${latest}`);
40
+ } else {
41
+ logger.info(`[mcp-bridge] Version ${current} is up to date`);
42
+ }
43
+ } catch (err) {
44
+ logger.warn(`[mcp-bridge] Version check failed: ${err instanceof Error ? err.message : err}`);
45
+ cachedUpdateInfo = {
46
+ currentVersion: current,
47
+ latestVersion: current,
48
+ updateAvailable: false,
49
+ updateCommand: updateCmd,
50
+ };
51
+ }
52
+
53
+ return cachedUpdateInfo;
54
+ }
55
+
56
+ /**
57
+ * Build the notice string to inject into the first tool response.
58
+ * Returns empty string if no update or already delivered.
59
+ */
60
+ export function getUpdateNotice(): string {
61
+ if (noticeDelivered || !cachedUpdateInfo?.updateAvailable) return "";
62
+ noticeDelivered = true;
63
+ return (
64
+ `\n\n---\nUpdate available: ${cachedUpdateInfo.currentVersion} → ${cachedUpdateInfo.latestVersion}\n` +
65
+ `Run: ${cachedUpdateInfo.updateCommand}`
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Reset the notice flag (for testing).
71
+ */
72
+ export function resetNoticeFlag(): void {
73
+ noticeDelivered = false;
74
+ }
75
+
76
+ /**
77
+ * Execute the actual npm update. Returns a result message.
78
+ */
79
+ export async function runUpdate(logger: Logger): Promise<string> {
80
+ const info = cachedUpdateInfo ?? await checkForUpdate(logger);
81
+
82
+ if (!info.updateAvailable) {
83
+ return `MCP Bridge is already up to date (v${info.currentVersion}).`;
84
+ }
85
+
86
+ logger.info(`[mcp-bridge] Running update: ${info.updateCommand}`);
87
+
88
+ try {
89
+ const output = await execAsync(info.updateCommand, 60_000);
90
+ // Invalidate cache so next check re-fetches
91
+ cachedUpdateInfo = null;
92
+ noticeDelivered = false;
93
+
94
+ // Verify new version
95
+ const newVersion = npmViewVersionSync(logger);
96
+ return (
97
+ `MCP Bridge updated: ${info.currentVersion} → ${newVersion}\n` +
98
+ `A restart is needed to load the new version.\n\n` +
99
+ `npm output:\n${output.trim()}`
100
+ );
101
+ } catch (err) {
102
+ const msg = err instanceof Error ? err.message : String(err);
103
+ logger.error(`[mcp-bridge] Update failed: ${msg}`);
104
+ return (
105
+ `Update failed. You can try manually:\n` +
106
+ `${info.updateCommand}\n\nError: ${msg}`
107
+ );
108
+ }
109
+ }
110
+
111
+ // --- helpers ---
112
+
113
+ function npmViewVersion(_logger: Logger): Promise<string> {
114
+ return new Promise((resolve, reject) => {
115
+ const timeout = setTimeout(() => reject(new Error("npm view timed out")), 10_000);
116
+ execCb(`npm view ${PACKAGE_NAME} version`, { encoding: "utf-8" }, (err, stdout) => {
117
+ clearTimeout(timeout);
118
+ if (err) return reject(err);
119
+ const ver = (stdout ?? "").trim();
120
+ if (!ver) return reject(new Error("empty version from npm"));
121
+ resolve(ver);
122
+ });
123
+ });
124
+ }
125
+
126
+ function npmViewVersionSync(_logger: Logger): string {
127
+ try {
128
+ return execSync(`npm view ${PACKAGE_NAME} version`, { encoding: "utf-8", timeout: 10_000 }).trim();
129
+ } catch {
130
+ return "unknown";
131
+ }
132
+ }
133
+
134
+ function execAsync(cmd: string, timeoutMs: number): Promise<string> {
135
+ return new Promise((resolve, reject) => {
136
+ const timeout = setTimeout(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)), timeoutMs);
137
+ execCb(cmd, { encoding: "utf-8", timeout: timeoutMs }, (err, stdout, stderr) => {
138
+ clearTimeout(timeout);
139
+ if (err) return reject(new Error(`${err.message}\n${stderr ?? ""}`));
140
+ resolve(stdout ?? "");
141
+ });
142
+ });
143
+ }
144
+
145
+ function isNewer(latest: string, current: string): boolean {
146
+ const l = latest.split(".").map(Number);
147
+ const c = current.split(".").map(Number);
148
+ for (let i = 0; i < Math.max(l.length, c.length); i++) {
149
+ const lv = l[i] ?? 0;
150
+ const cv = c[i] ?? 0;
151
+ if (lv > cv) return true;
152
+ if (lv < cv) return false;
153
+ }
154
+ return false;
155
+ }
@@ -0,0 +1,60 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { pickRegisteredToolName } from "../src/tool-naming.ts";
4
+
5
+ test("auto mode: collision causes second server tool to be prefixed", () => {
6
+ const globalNames = new Set<string>();
7
+
8
+ // "auto" (default) — no prefix unless collision
9
+ const first = pickRegisteredToolName("alpha", "search", "auto", new Set<string>(), globalNames);
10
+ globalNames.add(first);
11
+
12
+ const second = pickRegisteredToolName("beta", "search", "auto", new Set<string>(), globalNames);
13
+
14
+ assert.equal(first, "search");
15
+ assert.equal(second, "beta_search");
16
+ });
17
+
18
+ test("auto mode: no collision means no prefix", () => {
19
+ const globalNames = new Set<string>();
20
+
21
+ const first = pickRegisteredToolName("alpha", "search", "auto", new Set<string>(), globalNames);
22
+ globalNames.add(first);
23
+
24
+ const second = pickRegisteredToolName("beta", "list", "auto", new Set<string>(), globalNames);
25
+
26
+ assert.equal(first, "search");
27
+ assert.equal(second, "list");
28
+ });
29
+
30
+ test("auto mode: undefined defaults to auto", () => {
31
+ const globalNames = new Set<string>();
32
+
33
+ const first = pickRegisteredToolName("alpha", "search", undefined, new Set<string>(), globalNames);
34
+ globalNames.add(first);
35
+
36
+ const second = pickRegisteredToolName("beta", "search", undefined, new Set<string>(), globalNames);
37
+
38
+ assert.equal(first, "search");
39
+ assert.equal(second, "beta_search");
40
+ });
41
+
42
+ test("true mode: always prefixes", () => {
43
+ const globalNames = new Set<string>();
44
+
45
+ const name = pickRegisteredToolName("alpha", "search", true, new Set<string>(), globalNames);
46
+
47
+ assert.equal(name, "alpha_search");
48
+ });
49
+
50
+ test("false mode: never prefixes, uses suffix on collision", () => {
51
+ const globalNames = new Set<string>();
52
+
53
+ const first = pickRegisteredToolName("alpha", "search", false, new Set<string>(), globalNames);
54
+ globalNames.add(first);
55
+
56
+ const second = pickRegisteredToolName("beta", "search", false, new Set<string>(), globalNames);
57
+
58
+ assert.equal(first, "search");
59
+ assert.equal(second, "search_2");
60
+ });
@@ -0,0 +1,68 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveEnvVars, resolveEnvRecord, resolveArgs } from "../src/transport-base.ts";
4
+
5
+ test("resolveEnvRecord throws when env var is missing", () => {
6
+ assert.throws(
7
+ () => resolveEnvRecord({ TOKEN: "${MISSING_TEST_ENV}" }, "env key"),
8
+ /Missing required environment variable/
9
+ );
10
+ });
11
+
12
+ test("resolveArgs resolves env vars in args", () => {
13
+ const env = { MY_TOKEN: "secret123" };
14
+ const result = resolveArgs(["--token", "${MY_TOKEN}", "--verbose"], env);
15
+ assert.deepStrictEqual(result, ["--token", "secret123", "--verbose"]);
16
+ });
17
+
18
+ test("resolveArgs throws when env var is missing in args", () => {
19
+ assert.throws(
20
+ () => resolveArgs(["--token", "${MISSING_TEST_ENV}"], {}),
21
+ /Missing required environment variable/
22
+ );
23
+ });
24
+
25
+ test("resolveArgs passes through args without variables", () => {
26
+ const result = resolveArgs(["-y", "@llmindset/mcp-miro", "--verbose"], {});
27
+ assert.deepStrictEqual(result, ["-y", "@llmindset/mcp-miro", "--verbose"]);
28
+ });
29
+
30
+ test("resolveEnvRecord resolves headers with env vars", () => {
31
+ process.env.__TEST_MCP_TOKEN = "test-secret-456";
32
+ try {
33
+ const result = resolveEnvRecord(
34
+ { Authorization: "Bearer ${__TEST_MCP_TOKEN}" },
35
+ "header"
36
+ );
37
+ assert.deepStrictEqual(result, { Authorization: "Bearer test-secret-456" });
38
+ } finally {
39
+ delete process.env.__TEST_MCP_TOKEN;
40
+ }
41
+ });
42
+
43
+ test("resolveEnvRecord throws for missing header env var", () => {
44
+ assert.throws(
45
+ () => resolveEnvRecord({ Authorization: "Bearer ${MISSING_TEST_ENV}" }, "header"),
46
+ /Missing required environment variable/
47
+ );
48
+ });
49
+
50
+ test("resolveEnvVars resolves single value", () => {
51
+ process.env.__TEST_MCP_SINGLE = "hello";
52
+ try {
53
+ const result = resolveEnvVars("prefix-${__TEST_MCP_SINGLE}-suffix", "test");
54
+ assert.equal(result, "prefix-hello-suffix");
55
+ } finally {
56
+ delete process.env.__TEST_MCP_SINGLE;
57
+ }
58
+ });
59
+
60
+ test("resolveEnvVars uses extraEnv before process.env", () => {
61
+ process.env.__TEST_MCP_PRIO = "from-process";
62
+ try {
63
+ const result = resolveEnvVars("${__TEST_MCP_PRIO}", "test", { __TEST_MCP_PRIO: "from-extra" });
64
+ assert.equal(result, "from-extra");
65
+ } finally {
66
+ delete process.env.__TEST_MCP_PRIO;
67
+ }
68
+ });