Skip to content
This repository was archived by the owner on May 29, 2025. It is now read-only.
This repository was archived by the owner on May 29, 2025. It is now read-only.

The Environment Variable sent in the initializer are not added to the process. #16

@jamesrochabrun

Description

@jamesrochabrun

The Environment Variable Issue

Context

Trying to add an API Key for a mcp server like for brave search

{
  "mcpServers": {
    "brave-search": {
      "command": "npx",
      "args": [
        "-y",
        "@modelcontextprotocol/server-brave-search"
      ],
      "env": {
        "BRAVE_API_KEY": "YOUR_API_KEY_HERE"
      }
    }
  }
}

The Error

When initializing your MCPClient, the environment variable BRAVE_API_KEY wasn't being respected, causing this error:

Error: BRAVE_API_KEY environment variable is required.

Why It Happened

Looking at the original loadZshEnvironment implementation:

private static func loadZshEnvironment() throws -> [String: String] {
    let process = Process()
    process.launchPath = "/bin/zsh"
    process.arguments = ["-c", "source ~/.zshenv; source ~/.zprofile; source ~/.zshrc; source ~/.zshrc; printenv"]
    let env = try getProcessStdout(process: process)

    if let path = env?.split(separator: "\n").filter({ $0.starts(with: "PATH=") }).last {
        return ["PATH": String(path.dropFirst("PATH=".count))]
    } else {
        return ProcessInfo.processInfo.environment
    }
}

This function had two critical flaws:

  1. It returned ONLY the PATH variable from the shell environment (return ["PATH": String(path)]), discarding all other environment variables
  2. It didn't accept or merge user-provided variables like BRAVE_API_KEY

The potential Solution

private static func loadZshEnvironment(userEnv: [String: String]? = nil) throws -> [String: String] {
    // Load full shell environment
    let shellProcess = Process()
    shellProcess.executableURL = URL(fileURLWithPath: "/bin/zsh")
    shellProcess.arguments = ["-ilc", "printenv"]  // Interactive login shell
    
    let outputPipe = Pipe()
    shellProcess.standardOutput = outputPipe
    try shellProcess.run()
    shellProcess.waitUntilExit()
    
    // Parse ALL environment variables (not just PATH)
    var mergedEnv: [String: String] = [:]
    if let outputString = String(data: outputPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) {
        outputString.split(separator: "\n").forEach { line in
            let components = line.split(separator: "=", maxSplits: 1)
            if components.count == 2 {
                mergedEnv[String(components[0])] = String(components[1])
            }
        }
    }
    
    // Give priority to user-provided variables
    userEnv?.forEach { key, value in
        mergedEnv[key] = value
    }
    
    return mergedEnv
}

This works because:

  1. We use -ilc flags to load a complete interactive login shell environment
  2. We capture ALL environment variables, not just PATH
  3. We accept userEnv as a parameter and overlay these values with priority
  4. We preserve the original PATH from the shell, ensuring commands like npx are found

When you initialize your client with env: ["BRAVE_API_KEY": "..."], this value will be properly included in the final environment.

Complete Changes suggested by Claude:

private static func loadZshEnvironment(userEnv: [String: String]? = nil) throws -> [String: String] {
    // Load shell environment as base
    let shellProcess = Process()
    shellProcess.executableURL = URL(fileURLWithPath: "/bin/zsh")
    shellProcess.arguments = ["-ilc", "printenv"]
    
    let outputPipe = Pipe()
    shellProcess.standardOutput = outputPipe
    shellProcess.standardError = Pipe()
    
    try shellProcess.run()
    shellProcess.waitUntilExit()
    
    let data = outputPipe.fileHandleForReading.readDataToEndOfFile()
    guard let outputString = String(data: data, encoding: .utf8) else {
        logger.error("Failed to read environment from shell.")
        return ProcessInfo.processInfo.environment
    }
    
    // Parse shell environment
    var mergedEnv: [String: String] = [:]
    outputString.split(separator: "\n").forEach { line in
        let components = line.split(separator: "=", maxSplits: 1)
        guard components.count == 2 else { return }
        let key = String(components[0])
        let value = String(components[1])
        mergedEnv[key] = value
    }
    
    // Overlay user-defined environment variables explicitly
    userEnv?.forEach { key, value in
        mergedEnv[key] = value
    }
    
    // Log for debugging clarity
    logger.debug("Final merged environment: \(mergedEnv)")
    
    return mergedEnv
}

And

public static func stdioProcess(
  _ executable: String,
  args: [String] = [],
  cwd: String? = nil,
  env: [String: String]? = nil,
  verbose: Bool = false)
  throws -> Transport
{
  if verbose {
    let command = "\(executable) \(args.joined(separator: " "))"
    logger.log("Running ↪ \(command)")
  }

  // Create the process
  func path(for executable: String, env: [String: String]?) -> String? {
    guard !executable.contains("/") else {
      return executable
    }
    do {
      let path = try locate(executable: executable, env: env)
      return path.isEmpty ? nil : path
    } catch {
      // Most likely an error because we could not locate the executable
      return nil
    }
  }

  let process = Process()
  // In MacOS, zsh is the default since macOS Catalina 10.15.7. We can safely assume it is available.
  process.launchPath = "/bin/zsh"
  
  // Load shell environment and merge with user-provided environment
  process.environment = try loadZshEnvironment(userEnv: env)
  
  let command = "\(executable) \(args.joined(separator: " "))"
  process.arguments = ["-c"] + [command]

  // Working directory
  if let cwd {
    process.currentDirectoryPath = cwd
  }

  // Input/output
  let stdin = Pipe()
  let stdout = Pipe()
  let stderr = Pipe()
  process.standardInput = stdin
  process.standardOutput = stdout
  process.standardError = stderr

  return try stdioProcess(unlaunchedProcess: process, verbose: verbose)
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions