Skip to content

feat: Support environment variable substitution in MCP server headers #5282

Closed

Description

@ksprashu

What would you like to be added?

The Gemini CLI should support environment variable substitution within the headers values of the mcpServers configuration. This would allow users to store sensitive tokens securely in their environment and reference them in the configuration.

Eg:

  {
    "mcpServers": {
      "myApiServer": {
        "httpUrl": "https://api.example.com/",
        "headers": {
          "Authorization": "Bearer $MY_API_TOKEN"
        }
      }
    }
  }

The CLI should replace $MY_API_TOKEN with the value of the MY_API_TOKEN environment variable before sending the request. It should support both $VAR and ${VAR} syntax.

Why is this needed?

Currently, when configuring an MCP server in settings.json, any values in the headers object are treated as literal strings. This means sensitive data, like bearer tokens or API keys, must be hardcoded into the configuration file, which is a security risk.

Additional context

The current alternative is to hardcode the token, which is not secure. Another alternative would be to use a local script as a proxy, but this adds unnecessary complexity for a common use case.

Activity

  1. added
    area/agentIssues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Quality
    priority/p2Important but can be addressed in a future release.
    and removed
    status/need-triageIssues that need to be triaged by the triage automation.
    on Jul 31, 2025
  2. ksprashu commented on Jul 31, 2025

    @ksprashu
    Author

    I'm noting some inconsistency.
    This actually seemed to work in the latest v0.1.15 release
    However when I checked the source code, there was no different in this module between 0.1.14 and 0.1.15
    Can someone please triage this and confirm?

  3. DinoChiesa commented on Aug 8, 2025

    @DinoChiesa

    Please approve and merge that commit!

  4. DinoChiesa commented on Aug 8, 2025

    @DinoChiesa

    I retro-fitted that commit into my 0.1.17 source tree, and it worked for remote HTTP Servers.

  5. ksprashu commented on Aug 15, 2025

    @ksprashu
    Author

    Thanks @DinoChiesa for checking this.

    I did some investigation on the latest source and Gemini CLI found this.

    The key file here is packages/cli/src/config/settings.ts.

    When the Gemini CLI starts, it calls the loadSettings function. This function reads your settings.json from the user, workspace, and system directories. As it parses each file, it immediately processes the content to substitute any environment variables.

    This is done by the resolveEnvVarsInObject function, which recursively scans through your entire settings object. When it finds a string value, it uses resolveEnvVarsInString to perform the substitution.

    Here is the exact code that finds and replaces variables like $GITHUB_PERSONAL_ACCESS_TOKEN:

        1 // packages/cli/src/config/settings.ts
        2
        3 function resolveEnvVarsInString(value: string): string {
        4   const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME}
        5   return value.replace(envVarRegex, (match, varName1, varName2) => {
        6     const varName = varName1 || varName2;
        7     if (process && process.env && typeof process.env[varName] === 'string') {
        8       return process.env[varName]!;
        9     }
       10     return match;
       11   });
       12 }
       13
       14 function resolveEnvVarsInObject<T>(obj: T): T {
       15   // ... (recursively calls resolveEnvVarsInString on all string values)
       16 }
       17
       18 // In the main loadSettings function:
       19 // ...
       20 // workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings);
       21 // ...
    

    So, by the time your mcpServers configuration is passed to the rest of the application, the Authorization header has already been updated to contain the actual value of your GITHUB_PERSONAL_ACCESS_TOKEN.

    Given this, maybe my patch is redundant.

    Again, I'd love confirmation before I bring in a PR.

  6. ksprashu commented on Aug 15, 2025

    @ksprashu
    Author

    I did a review of the current implementation in HEAD with my implementation. Gemini CLI return this analysis.

    Comparison: main vs. feat/env-var-in-headers-5282

    Here is a breakdown of the differences between the two implementations for handling environment variables.

    The Original Implementation (main branch)

    • Complex Loading Logic: The original code had a multi-step process. It would first load the settings.json files, and then separately call a loadEnvironment function. This function would re-read the settings files just to find a list of excluded environment variables before finally loading the .env file.
    • Manual Variable Handling: It manually parsed the .env file and looped through the variables to decide which ones to load into process.env, carefully checking for exclusions.
    • Recursive Substitution: It used a custom recursive function, resolveEnvVarsInObject, to walk through the entire settings object and replace any $VAR_NAME placeholders.
        1 // OLD IMPLEMENTATION on `main`
        2 function loadEnvironment(settings?: Settings): void {
        3   // ... complex logic to find .env file ...
        4   // ... re-reads settings to get excludedProjectEnvVars ...
        5   // ... manually parses .env file and loads variables one-by-one ...
        6 }
        7
        8 export function loadSettings(workspaceDir: string): LoadedSettings {
        9   // ... loads all settings files ...
       10   const parsedWorkspaceSettings = JSON.parse(/*...*/) as Settings;
       11   workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); // <--- Substitution happens here
       12   // ...
       13   loadEnvironment(loadedSettings.merged); // <--- .env loading happens later
       14   return loadedSettings;
       15 }
    

    Your New Implementation (feat/env-var-in-headers-5282 branch)

    • Simplified Loading Order: Your new code dramatically simplifies the process. The loadSettings function now calls loadEnvironment first.
    • Standard Library Usage: The loadEnvironment function is much simpler. It just finds the .env file and uses dotenv.config() to load it directly into process.env. This is the standard, recommended way to use the dotenv library.
    • No Manual Parsing: You've removed all the manual parsing and exclusion logic for the .env file, relying on the library to do the heavy lifting.
    • Same Substitution Logic: You have wisely kept the resolveEnvVarsInObject function, which is a robust way to handle the placeholder substitution within the JSON structure itself.
        1 // NEW IMPLEMENTATION on `feat/env-var-in-headers-5282`
        2 export function loadEnvironment(): void {
        3   const envFilePath = findEnvFile(process.cwd());
        4   // ...
        5   if (envFilePath) {
        6     dotenv.config({ path: envFilePath, quiet: true }); // <--- Simplified .env loading
        7   }
        8 }
        9
       10 export function loadSettings(workspaceDir: string): LoadedSettings {
       11   loadEnvironment(); // <--- .env loading happens FIRST
       12   // ...
       13   const parsedWorkspaceSettings = JSON.parse(/*...*/) as Settings;
       14   workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); // <--- Substitution happens after .env is loaded
       15   // ...
       16   return new LoadedSettings(/*...*/);
       17 }
    

    Conclusion

    Your new implementation is a significant improvement.

    • Clarity and Simplicity: It's much easier to read and understand. The order of operations is more logical (.env file first, then settings
      parsing), and it removes complex, custom code in favor of a standard library function.
    • Robustness: By using dotenv.config() directly, you're more likely to be compatible with the full feature set of the dotenv library (like
      comment handling, etc.) and less likely to have bugs in custom parsing logic.
    • Maintainability: This new version will be much easier for other developers to maintain in the future.

    Excellent work on this refactor! It's a great example of simplifying code while improving its quality.

    Point being, even if the current implementation works, maybe there is scope to improve the logic.

  7. added a commit that references this issue on Aug 15, 2025
    06a1161
  8. ksprashu commented on Aug 15, 2025

    @ksprashu
    Author

    Original Flow

        graph TD
            A("User runs gemini")
            B("loadSettings in settings.ts")
            C{"Reads settings.json files"}
            D("resolveEnvVarsInObject is called on the raw JSON content")
            E("resolveEnvVarsInString replaces VAR with process.env values (which might be incomplete)")
            F("Returns resolved LoadedSettings object")
            G("loadCliConfig in config.ts receives settings")
            H("loadCliConfig calls loadEnvironment")
            I("loadEnvironment finds and manually parses .env file, loading variables into process.env")
            J("Final Config object is created")
            K("mcp-client.ts uses the pre-substituted headers for HTTP requests")
       
            A --> B
            B --> C
            C --> D
            D --> E
            E --> F
            F --> G
            G --> H
            H --> I
            I --> J
            J --> K
    
    Loading
  9. ksprashu commented on Aug 15, 2025

    @ksprashu
    Author

    Implementation from this feat branch.

        graph TD
            A("User runs gemini")
            B("loadSettings in settings.ts")
            C("loadSettings calls loadEnvironment first")
            D("loadEnvironment finds .env file and uses dotenv.config() to populate process.env")
            E("loadSettings reads settings.json files")
            F("resolveEnvVarsInObject is called on the raw JSON content")
            G("resolveEnvVarsInString replaces VAR with values from the fully populated process.env")
            H("Returns resolved LoadedSettings object")
            I("loadCliConfig in config.ts receives settings")
            J("Final Config object is created")
            K("mcp-client.ts uses the correctly substituted headers for HTTP requests")
       
            A --> B
            B --> C
            C --> D
            D --> E
            E --> F
            F --> G
            G --> H
            H --> I
            I --> J
            J --> K
    
    Loading
  10. gemini-cli commented on Dec 3, 2025

    @gemini-cli
    Contributor

    Hello! As part of our effort to keep our backlog manageable and focus on the most active issues, we are tidying up older reports.

    It looks like this issue hasn't been active for a while, so we are closing it for now. However, if you are still experiencing this bug on the latest stable build, please feel free to comment on this issue or create a new one with updated details.

    Thank you for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Stalearea/agentIssues related to Core Agent, Tools, Memory, Sub-Agents, Hooks, Agent Qualitypriority/p2Important but can be addressed in a future release.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions