Skip to content

Commit 73f8792

Browse files
committed
fix(mcp): evaluate environment variables at transport connection time
Fixes #93144 Prior to this change, templates in MCP config fields (such as `env` and `headers`) were resolved during Gateway startup. As a result, any dynamic rotation of underlying environment variables required a full Gateway restart. This commit shifts the environment variable substitution logic to the MCP transport connection layer. We now intercept the SDK's fetch invocations (for HTTP/SSE) and child process spawn routines (for stdio), dynamically substituting variables based on the live `process.env` state at the exact moment of request, cleanly solving the credential rotation limits.
1 parent 884a6a1 commit 73f8792

5 files changed

Lines changed: 80 additions & 11 deletions

File tree

src/agents/mcp-transport.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1212
import type { FetchLike, Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
1313
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
14+
import { substituteString, containsEnvVarReference } from "../config/env-substitution.js";
15+
import { isDangerousHostEnvVarName } from "../infra/host-env-security.js";
1416
import { logDebug } from "../logger.js";
1517
import {
1618
buildMcpHttpFetch,
@@ -95,10 +97,24 @@ export function resolveMcpTransport(
9597
return null;
9698
}
9799
if (resolved.kind === "stdio") {
100+
const processEnv = process.env;
101+
const finalEnv: Record<string, string> = {};
102+
for (const [key, value] of Object.entries(resolved.env || {})) {
103+
if (isDangerousHostEnvVarName(key)) {
104+
throw new Error(
105+
`Dynamic environment variable substitution generated a dangerous host environment variable: ${key}`,
106+
);
107+
}
108+
finalEnv[key] =
109+
value && containsEnvVarReference(value)
110+
? substituteString(value, processEnv, "mcp.servers.*.env")
111+
: value;
112+
}
113+
98114
const transport = new OpenClawStdioClientTransport({
99115
command: resolved.command,
100116
args: resolved.args,
101-
env: resolved.env,
117+
env: finalEnv,
102118
cwd: resolved.cwd,
103119
stderr: "pipe",
104120
});
@@ -120,22 +136,50 @@ export function resolveMcpTransport(
120136
config: resolved.oauth,
121137
})
122138
: undefined;
139+
const headers =
140+
resolved.auth === "oauth" ? withoutMcpAuthorizationHeader(resolved.headers) : resolved.headers;
141+
123142
const baseFetch = buildMcpHttpFetch({
124143
sslVerify: resolved.sslVerify,
125144
clientCert: resolved.clientCert,
126145
clientKey: resolved.clientKey,
127146
resourceUrl: resolved.url,
128147
});
129-
const headers =
130-
resolved.auth === "oauth" ? withoutMcpAuthorizationHeader(resolved.headers) : resolved.headers;
148+
149+
const substitutingFetch: FetchLike = (url, init) => {
150+
let finalInit = init;
151+
if (init?.headers) {
152+
const mergedHeaders = new Headers(init.headers);
153+
let needsReplace = false;
154+
const processEnv = process.env;
155+
for (const [key, value] of mergedHeaders.entries()) {
156+
if (value && containsEnvVarReference(value)) {
157+
mergedHeaders.set(key, substituteString(value, processEnv, "mcp.servers.*.headers"));
158+
needsReplace = true;
159+
}
160+
}
161+
if (needsReplace) {
162+
// We reconstruct as a plain object to avoid surprising any fetch polyfills or assertions
163+
// that expect a plain object, particularly in tests.
164+
const plainHeaders: Record<string, string> = {};
165+
for (const [key, value] of mergedHeaders.entries()) {
166+
plainHeaders[key] = value;
167+
}
168+
finalInit = { ...init, headers: plainHeaders };
169+
}
170+
}
171+
return baseFetch(url, finalInit);
172+
};
173+
131174
const httpFetch =
132175
resolved.auth === "oauth"
133176
? withSameOriginMcpHttpHeaders({
134-
fetchFn: baseFetch,
177+
fetchFn: substitutingFetch,
135178
headers,
136179
resourceUrl: resolved.url,
137180
})
138-
: baseFetch;
181+
: substitutingFetch;
182+
139183
if (resolved.transportType === "streamable-http") {
140184
return {
141185
transport: new StreamableHTTPClientTransport(new URL(resolved.url), {
@@ -150,11 +194,15 @@ export function resolveMcpTransport(
150194
supportsParallelToolCalls: resolved.supportsParallelToolCalls,
151195
};
152196
}
153-
const sseHeaders: Record<string, string> = { ...headers };
154-
const hasHeaders = Object.keys(sseHeaders).length > 0;
197+
198+
const sseHeaders = headers ? { ...headers } : {};
199+
if (resolved.auth === "oauth" && sseHeaders.authorization) {
200+
delete sseHeaders.authorization;
201+
}
202+
155203
return {
156204
transport: new SSEClientTransport(new URL(resolved.url), {
157-
requestInit: resolved.auth === "oauth" || !hasHeaders ? undefined : { headers: sseHeaders },
205+
requestInit: resolved.auth === "oauth" || !headers ? undefined : { headers },
158206
fetch: httpFetch,
159207
eventSourceInit: {
160208
fetch: buildSseEventSourceFetch(resolved.auth === "oauth" ? {} : sseHeaders, httpFetch),

src/config/env-substitution.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,14 @@ export type EnvSubstitutionWarning = {
8383
configPath: string;
8484
};
8585

86-
type SubstituteOptions = {
86+
export type SubstituteOptions = {
8787
/** When set, missing vars call this instead of throwing and the original placeholder is preserved. */
8888
onMissing?: (warning: EnvSubstitutionWarning) => void;
89+
/** Paths to skip substitution for. Supports simple wildcard '*' matches like 'mcp.servers.*.env'. */
90+
ignorePaths?: string[];
8991
};
9092

91-
function substituteString(
93+
export function substituteString(
9294
value: string,
9395
env: NodeJS.ProcessEnv,
9496
configPath: string,
@@ -168,6 +170,19 @@ function substituteAny(
168170
path: string,
169171
opts?: SubstituteOptions,
170172
): unknown {
173+
if (
174+
opts?.ignorePaths &&
175+
opts.ignorePaths.some((p) => {
176+
if (!p.includes("*")) {
177+
return path === p || path.startsWith(`${p}.`) || path.startsWith(`${p}[`);
178+
}
179+
const regexStr = "^" + p.replace(/\./g, "\\.").replace(/\*/g, "[^.]+") + "($|\\.|\\[)";
180+
return new RegExp(regexStr).test(path);
181+
})
182+
) {
183+
return value;
184+
}
185+
171186
if (typeof value === "string") {
172187
return substituteString(value, env, path, opts);
173188
}

src/config/gateway-dispatch-config.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@ function resolveGatewayDispatchEnvVars(config: unknown, env: NodeJS.ProcessEnv):
9898
if (isPlainRecord(config) && Object.hasOwn(config, "env")) {
9999
applyConfigEnvVars(config as OpenClawConfig, env);
100100
}
101-
return resolveConfigEnvVars(config, env, { onMissing: () => undefined });
101+
return resolveConfigEnvVars(config, env, {
102+
onMissing: () => undefined,
103+
ignorePaths: ["mcp.servers.*.env", "mcp.servers.*.headers"],
104+
});
102105
}
103106

104107
function readRawGatewayDispatchConfig(options: GatewayDispatchConfigReadOptions = {}): {

src/config/io.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1370,6 +1370,7 @@ function resolveConfigForRead(
13701370
return {
13711371
resolvedConfigRaw: resolveConfigEnvVars(resolvedIncludes, env, {
13721372
onMissing: (w) => envWarnings.push(w),
1373+
ignorePaths: ["mcp.servers.*.env", "mcp.servers.*.headers"],
13731374
}),
13741375
// Capture env snapshot after substitution for write-time ${VAR} restoration.
13751376
envSnapshotForRestore: { ...env } as Record<string, string | undefined>,

src/plugins/loader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,13 +1360,15 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) {
13601360
shouldResolveRawConfigEnvVars
13611361
? (resolveConfigEnvVars(rawConfig, env, {
13621362
onMissing: () => undefined,
1363+
ignorePaths: ["mcp.servers.*.env", "mcp.servers.*.headers"],
13631364
}) as OpenClawConfig)
13641365
: rawConfig,
13651366
env,
13661367
);
13671368
const activationSourceConfig = shouldResolveRawConfigEnvVars
13681369
? (resolveConfigEnvVars(rawActivationSourceConfig, env, {
13691370
onMissing: () => undefined,
1371+
ignorePaths: ["mcp.servers.*.env", "mcp.servers.*.headers"],
13701372
}) as OpenClawConfig)
13711373
: rawActivationSourceConfig;
13721374
const normalized = normalizePluginsConfig(cfg.plugins);

0 commit comments

Comments
 (0)