/ services / mcp / headersHelper.ts
headersHelper.ts
  1  import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
  2  import { checkHasTrustDialogAccepted } from '../../utils/config.js'
  3  import { logAntError } from '../../utils/debug.js'
  4  import { errorMessage } from '../../utils/errors.js'
  5  import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js'
  6  import { logError, logMCPDebug, logMCPError } from '../../utils/log.js'
  7  import { jsonParse } from '../../utils/slowOperations.js'
  8  import { logEvent } from '../analytics/index.js'
  9  import type {
 10    McpHTTPServerConfig,
 11    McpSSEServerConfig,
 12    McpWebSocketServerConfig,
 13    ScopedMcpServerConfig,
 14  } from './types.js'
 15  
 16  /**
 17   * Check if the MCP server config comes from project settings (projectSettings or localSettings)
 18   * This is important for security checks
 19   */
 20  function isMcpServerFromProjectOrLocalSettings(
 21    config: ScopedMcpServerConfig,
 22  ): boolean {
 23    return config.scope === 'project' || config.scope === 'local'
 24  }
 25  
 26  /**
 27   * Get dynamic headers for an MCP server using the headersHelper script
 28   * @param serverName The name of the MCP server
 29   * @param config The MCP server configuration
 30   * @returns Headers object or null if not configured or failed
 31   */
 32  export async function getMcpHeadersFromHelper(
 33    serverName: string,
 34    config: McpSSEServerConfig | McpHTTPServerConfig | McpWebSocketServerConfig,
 35  ): Promise<Record<string, string> | null> {
 36    if (!config.headersHelper) {
 37      return null
 38    }
 39  
 40    // Security check for project/local settings
 41    // Skip trust check in non-interactive mode (e.g., CI/CD, automation)
 42    if (
 43      'scope' in config &&
 44      isMcpServerFromProjectOrLocalSettings(config as ScopedMcpServerConfig) &&
 45      !getIsNonInteractiveSession()
 46    ) {
 47      // Check if trust has been established for this project
 48      const hasTrust = checkHasTrustDialogAccepted()
 49      if (!hasTrust) {
 50        const error = new Error(
 51          `Security: headersHelper for MCP server '${serverName}' executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`,
 52        )
 53        logAntError('MCP headersHelper invoked before trust check', error)
 54        logEvent('tengu_mcp_headersHelper_missing_trust', {})
 55        return null
 56      }
 57    }
 58  
 59    try {
 60      logMCPDebug(serverName, 'Executing headersHelper to get dynamic headers')
 61      const execResult = await execFileNoThrowWithCwd(config.headersHelper, [], {
 62        shell: true,
 63        timeout: 10000,
 64        // Pass server context so one helper script can serve multiple MCP servers
 65        // (git credential-helper style). See deshaw/anthropic-issues#28.
 66        env: {
 67          ...process.env,
 68          CLAUDE_CODE_MCP_SERVER_NAME: serverName,
 69          CLAUDE_CODE_MCP_SERVER_URL: config.url,
 70        },
 71      })
 72      if (execResult.code !== 0 || !execResult.stdout) {
 73        throw new Error(
 74          `headersHelper for MCP server '${serverName}' did not return a valid value`,
 75        )
 76      }
 77      const result = execResult.stdout.trim()
 78  
 79      const headers = jsonParse(result)
 80      if (
 81        typeof headers !== 'object' ||
 82        headers === null ||
 83        Array.isArray(headers)
 84      ) {
 85        throw new Error(
 86          `headersHelper for MCP server '${serverName}' must return a JSON object with string key-value pairs`,
 87        )
 88      }
 89  
 90      // Validate all values are strings
 91      for (const [key, value] of Object.entries(headers)) {
 92        if (typeof value !== 'string') {
 93          throw new Error(
 94            `headersHelper for MCP server '${serverName}' returned non-string value for key "${key}": ${typeof value}`,
 95          )
 96        }
 97      }
 98  
 99      logMCPDebug(
100        serverName,
101        `Successfully retrieved ${Object.keys(headers).length} headers from headersHelper`,
102      )
103      return headers as Record<string, string>
104    } catch (error) {
105      logMCPError(
106        serverName,
107        `Error getting headers from headersHelper: ${errorMessage(error)}`,
108      )
109      logError(
110        new Error(
111          `Error getting MCP headers from headersHelper for server '${serverName}': ${errorMessage(error)}`,
112        ),
113      )
114      // Return null instead of throwing to avoid blocking the connection
115      return null
116    }
117  }
118  
119  /**
120   * Get combined headers for an MCP server (static + dynamic)
121   * @param serverName The name of the MCP server
122   * @param config The MCP server configuration
123   * @returns Combined headers object
124   */
125  export async function getMcpServerHeaders(
126    serverName: string,
127    config: McpSSEServerConfig | McpHTTPServerConfig | McpWebSocketServerConfig,
128  ): Promise<Record<string, string>> {
129    const staticHeaders = config.headers || {}
130    const dynamicHeaders =
131      (await getMcpHeadersFromHelper(serverName, config)) || {}
132  
133    // Dynamic headers override static headers if both are present
134    return {
135      ...staticHeaders,
136      ...dynamicHeaders,
137    }
138  }