Skip to content

[Feature]: Support per-request dynamic resolution of ENV_VAR substitution in MCP transport headers and env #93144

Description

@ai-apply9527

Summary

MCP ${ENV_VAR} substitution in headers and env should be re-evaluated from process.env at connection/request time, not frozen at Gateway startup.

Problem to solve

Documentation claims ${ENV_VAR} values in MCP headers are "read from the environment at runtime" and "resolved at connection time" (docs/cli/mcp-config.md:180, :713), but resolveConfigEnvVars() executes once at Gateway startup. Runtime updates to process.env have no effect on already-created MCP transports. All three transport types (stdio, SSE, streamable-http) are affected.

This blocks credential rotation (OAuth token refresh), multi-tenant header switching, session-specific auth injection, and integration with apps that dynamically update process.env (e.g., MatrixAssistant, Electron apps). The only workaround is a full Gateway restart, which drops active sessions and WebSocket

Proposed solution

Preserve ${ENV_VAR} templates from the pre-substitution config and re-evaluate them in the transport layer for all three transport types. The resolution granularity differs by transport due to protocol and OS constraints:

streamable-http (per-request) — Pass a custom fetch closure to StreamableHTTPClientTransport that re-evaluates ${ENV_VAR} from process.env on every HTTP request. Each tool call is an independent HTTP request, so dynamic headers take effect immediately with no connection constraints.

SSE (per-request on POST channel; EventSource GET only on reconnection) — Pass a custom fetch via eventSourceInit for the SSE transport. The POST channel (tool calls) supports per-request dynamic headers. The long-lived EventSource GET connection only sends headers at connection establishment; dynamic header changes on the GET side only take effect on reconnection.

stdio (per-spawn) — Child process env is fixed at OS spawn time (execve). Re-evaluate ${ENV_VAR} when the child process is spawned (e.g., on Transport recreation after hot-reload). Runtime process.env changes in the parent do not propagate to the already-running child without a respawn.

Implementation approach: Reuse existing substituteString() and containsEnvVarReference() helpers from src/config/env-substitution.ts. For HTTP transports, wrap the SDK's fetch parameter with a closure that re-substitutes ${ENV_VAR} per-request. The MCP TypeScript SDK already supports a fetch parameter on both SSEClientTransport and StreamableHTTPClientTransport (SDK PR #721).

All three transport types must be addressed as part of this feature. The granularity of dynamic resolution differs due to protocol and OS constraints:

Transport Dynamic resolution granularity Technical constraint
streamable-http Per-request — next MCP tool call uses the new value None — each tool call is an independent HTTP request
SSE Per-request on POST channel; EventSource GET only on reconnection SSE uses a long-lived EventSource connection; headers are only sent when the connection is established
stdio Per-spawn${ENV_VAR} re-evaluated when child process is (re)spawned Child process env is fixed at OS spawn time (execve); requires Transport recreation to pick up env changes

Alternatives considered

Gateway restart (openclaw gateway restart) — Disruptive: active sessions are lost, WebSocket connections drop, service is interrupted. Not viable for production credential rotation.

HTTP proxy injecting dynamic headers — Adds infrastructure complexity, requires external deployment, and does not help stdio transports. Not a source-level solution.

Claude Code headersHelper approach — Claude Code executes an external shell script per-connection to resolve dynamic headers. This adds a security surface (arbitrary script execution), requires CLI-style lifecycle management (ill-suited for daemon mode), and is more complex than the fetch closure approach. OAuth auth provider is a more natural fit for OpenClaw's daemon lifecycle if token refresh is needed beyond env-var resolution.

Config hot-reload only — The existing hot-reload mechanism (configFingerprint) only triggers on mcp.servers.* config file edits, not on process.env changes. Adding process.env to the fingerprint would cause spurious restarts. Per-request resolution is the correct granularity.

Impact

  • Affected users: All users who use ${ENV_VAR} in MCP headers or env parameters for any transport type
  • Severity: High — blocks credential rotation, multi-tenant scenarios, and dynamic auth workflows without a disruptive Gateway restart
  • Frequency: Always — this is an architectural limitation, not an edge case. Any ${ENV_VAR} usage is affected.
  • Consequence:
    • OAuth token rotation: security compliance violation (stale tokens remain in use)
    • Multi-tenant scenarios: cannot switch tenant headers without restart
    • Session-specific auth: cannot inject per-session tokens
    • App integrations: MatrixAssistant, Electron apps, and embedded use cases that dynamically update process.env cannot propagate changes

Evidence/examples

Documentation claims runtime resolution:

docs/cli/mcp-config.md:180:

The ${MY_SECRET_TOKEN} value is read from the environment at runtime.

docs/cli/mcp-config.md:713 (Security Properties table):

Runtime resolution: Environment variables resolved at connection time

Source code shows one-time startup substitution:

// src/config/env-substitution.ts:197-202
export function resolveConfigEnvVars(
  obj: unknown,
  env: NodeJS.ProcessEnv = process.env,
  opts?: SubstituteOptions,
): unknown {
  return substituteAny(obj, env, "", opts);  // one-time substitution
}
// src/agents/mcp-transport.ts:102-110 (streamable-http)
transport: new StreamableHTTPClientTransport(new URL(resolved.url), {
  requestInit: resolved.headers ? { headers: resolved.headers } : undefined,
  // no fetch parameter for dynamic headers
})
// src/agents/mcp-transport.ts:116-125 (SSE)
transport: new SSEClientTransport(new URL(resolved.url), {
  requestInit: hasHeaders ? { headers } : undefined,
  fetch: fetchWithUndici,  // static fetch, not dynamic
})

Config loading flow (current — values frozen at startup):

Gateway startup
  → readConfigFileSnapshot()        (read raw config)
  → resolveConfigEnvVars()           (substitute ${VAR} — one-time)
  → mcp.servers.*.headers/env =      "fixed literal values"
  → resolveMcpTransportConfig()      (parse MCP config)
  → new *ClientTransport({ requestInit: { headers } })  (headers frozen)
  → Runtime: MCP tool call → uses frozen headers (cannot be updated)

Competitive analysis:

Additional information

  • Must remain backward-compatible: existing ${ENV_VAR} configs should work unchanged with the new dynamic resolution behavior.
  • Security: resolveDynamicEnv() must apply the same isDangerousHostEnvVarName() security filter used by the existing toMcpEnvRecord() to block NODE_OPTIONS, LD_PRELOAD, BASH_ENV, etc.
  • Hot-reload fingerprint does not include process.env; this is intentional. Dynamic resolution at the transport layer is the correct approach, not fingerprint expansion.
  • SDK dependency: requires @modelcontextprotocol/sdk version that supports fetch parameter on both SSEClientTransport and StreamableHTTPClientTransport (available since SDK PR docs(showcase): add Visual Morning Briefing Scene #721).
  • Detailed implementation plan: docs/analysis/mcp-dynamic-params-fix-plan.md

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:needs-maintainer-reviewClawSweeper marked this issue as needing maintainer review before automation.clawsweeper:needs-product-decisionClawSweeper marked this issue as needing a product or behavior decision.clawsweeper:needs-security-reviewClawSweeper marked this issue as needing security-sensitive review.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.enhancementNew feature or requestimpact:auth-providerAuth, provider routing, model choice, or SecretRef resolution may break.impact:securitySecurity boundary, credential, authz, sandbox, or sensitive-data risk.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions