/ services / mcp / config.ts
config.ts
   1  import { feature } from 'bun:bundle'
   2  import { chmod, open, rename, stat, unlink } from 'fs/promises'
   3  import mapValues from 'lodash-es/mapValues.js'
   4  import memoize from 'lodash-es/memoize.js'
   5  import { dirname, join, parse } from 'path'
   6  import { getPlatform } from 'src/utils/platform.js'
   7  import type { PluginError } from '../../types/plugin.js'
   8  import { getPluginErrorMessage } from '../../types/plugin.js'
   9  import { isClaudeInChromeMCPServer } from '../../utils/claudeInChrome/common.js'
  10  import {
  11    getCurrentProjectConfig,
  12    getGlobalConfig,
  13    saveCurrentProjectConfig,
  14    saveGlobalConfig,
  15  } from '../../utils/config.js'
  16  import { getCwd } from '../../utils/cwd.js'
  17  import { logForDebugging } from '../../utils/debug.js'
  18  import { getErrnoCode } from '../../utils/errors.js'
  19  import { getFsImplementation } from '../../utils/fsOperations.js'
  20  import { safeParseJSON } from '../../utils/json.js'
  21  import { logError } from '../../utils/log.js'
  22  import { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js'
  23  import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js'
  24  import { isSettingSourceEnabled } from '../../utils/settings/constants.js'
  25  import { getManagedFilePath } from '../../utils/settings/managedPath.js'
  26  import { isRestrictedToPluginOnly } from '../../utils/settings/pluginOnlyPolicy.js'
  27  import {
  28    getInitialSettings,
  29    getSettingsForSource,
  30  } from '../../utils/settings/settings.js'
  31  import {
  32    isMcpServerCommandEntry,
  33    isMcpServerNameEntry,
  34    isMcpServerUrlEntry,
  35    type SettingsJson,
  36  } from '../../utils/settings/types.js'
  37  import type { ValidationError } from '../../utils/settings/validation.js'
  38  import { jsonStringify } from '../../utils/slowOperations.js'
  39  import {
  40    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  41    logEvent,
  42  } from '../analytics/index.js'
  43  import { fetchClaudeAIMcpConfigsIfEligible } from './claudeai.js'
  44  import { expandEnvVarsInString } from './envExpansion.js'
  45  import {
  46    type ConfigScope,
  47    type McpHTTPServerConfig,
  48    type McpJsonConfig,
  49    McpJsonConfigSchema,
  50    type McpServerConfig,
  51    McpServerConfigSchema,
  52    type McpSSEServerConfig,
  53    type McpStdioServerConfig,
  54    type McpWebSocketServerConfig,
  55    type ScopedMcpServerConfig,
  56  } from './types.js'
  57  import { getProjectMcpServerStatus } from './utils.js'
  58  
  59  /**
  60   * Get the path to the managed MCP configuration file
  61   */
  62  export function getEnterpriseMcpFilePath(): string {
  63    return join(getManagedFilePath(), 'managed-mcp.json')
  64  }
  65  
  66  /**
  67   * Internal utility: Add scope to server configs
  68   */
  69  function addScopeToServers(
  70    servers: Record<string, McpServerConfig> | undefined,
  71    scope: ConfigScope,
  72  ): Record<string, ScopedMcpServerConfig> {
  73    if (!servers) {
  74      return {}
  75    }
  76    const scopedServers: Record<string, ScopedMcpServerConfig> = {}
  77    for (const [name, config] of Object.entries(servers)) {
  78      scopedServers[name] = { ...config, scope }
  79    }
  80    return scopedServers
  81  }
  82  
  83  /**
  84   * Internal utility: Write MCP config to .mcp.json file.
  85   * Preserves file permissions and flushes to disk before rename.
  86   * Uses the original path for rename (does not follow symlinks).
  87   */
  88  async function writeMcpjsonFile(config: McpJsonConfig): Promise<void> {
  89    const mcpJsonPath = join(getCwd(), '.mcp.json')
  90  
  91    // Read existing file permissions to preserve them
  92    let existingMode: number | undefined
  93    try {
  94      const stats = await stat(mcpJsonPath)
  95      existingMode = stats.mode
  96    } catch (e: unknown) {
  97      const code = getErrnoCode(e)
  98      if (code !== 'ENOENT') {
  99        throw e
 100      }
 101      // File doesn't exist yet -- no permissions to preserve
 102    }
 103  
 104    // Write to temp file, flush to disk, then atomic rename
 105    const tempPath = `${mcpJsonPath}.tmp.${process.pid}.${Date.now()}`
 106    const handle = await open(tempPath, 'w', existingMode ?? 0o644)
 107    try {
 108      await handle.writeFile(jsonStringify(config, null, 2), {
 109        encoding: 'utf8',
 110      })
 111      await handle.datasync()
 112    } finally {
 113      await handle.close()
 114    }
 115  
 116    try {
 117      // Restore original file permissions on the temp file before rename
 118      if (existingMode !== undefined) {
 119        await chmod(tempPath, existingMode)
 120      }
 121      await rename(tempPath, mcpJsonPath)
 122    } catch (e: unknown) {
 123      // Clean up temp file on failure
 124      try {
 125        await unlink(tempPath)
 126      } catch {
 127        // Best-effort cleanup
 128      }
 129      throw e
 130    }
 131  }
 132  
 133  /**
 134   * Extract command array from server config (stdio servers only)
 135   * Returns null for non-stdio servers
 136   */
 137  function getServerCommandArray(config: McpServerConfig): string[] | null {
 138    // Non-stdio servers don't have commands
 139    if (config.type !== undefined && config.type !== 'stdio') {
 140      return null
 141    }
 142    const stdioConfig = config as McpStdioServerConfig
 143    return [stdioConfig.command, ...(stdioConfig.args ?? [])]
 144  }
 145  
 146  /**
 147   * Check if two command arrays match exactly
 148   */
 149  function commandArraysMatch(a: string[], b: string[]): boolean {
 150    if (a.length !== b.length) {
 151      return false
 152    }
 153    return a.every((val, idx) => val === b[idx])
 154  }
 155  
 156  /**
 157   * Extract URL from server config (remote servers only)
 158   * Returns null for stdio/sdk servers
 159   */
 160  function getServerUrl(config: McpServerConfig): string | null {
 161    return 'url' in config ? config.url : null
 162  }
 163  
 164  /**
 165   * CCR proxy URL path markers. In remote sessions, claude.ai connectors arrive
 166   * via --mcp-config with URLs rewritten to route through the CCR/session-ingress
 167   * SHTTP proxy. The original vendor URL is preserved in the mcp_url query param
 168   * so the proxy knows where to forward. See api-go/ccr/internal/ccrshared/
 169   * mcp_url_rewriter.go and api-go/ccr/internal/mcpproxy/proxy.go.
 170   */
 171  const CCR_PROXY_PATH_MARKERS = [
 172    '/v2/session_ingress/shttp/mcp/',
 173    '/v2/ccr-sessions/',
 174  ]
 175  
 176  /**
 177   * If the URL is a CCR proxy URL, extract the original vendor URL from the
 178   * mcp_url query parameter. Otherwise return the URL unchanged. This lets
 179   * signature-based dedup match a plugin's raw vendor URL against a connector's
 180   * rewritten proxy URL when both point at the same MCP server.
 181   */
 182  export function unwrapCcrProxyUrl(url: string): string {
 183    if (!CCR_PROXY_PATH_MARKERS.some(m => url.includes(m))) {
 184      return url
 185    }
 186    try {
 187      const parsed = new URL(url)
 188      const original = parsed.searchParams.get('mcp_url')
 189      return original || url
 190    } catch {
 191      return url
 192    }
 193  }
 194  
 195  /**
 196   * Compute a dedup signature for an MCP server config.
 197   * Two configs with the same signature are considered "the same server" for
 198   * plugin deduplication. Ignores env (plugins always inject CLAUDE_PLUGIN_ROOT)
 199   * and headers (same URL = same server regardless of auth).
 200   * Returns null only for configs with neither command nor url (sdk type).
 201   */
 202  export function getMcpServerSignature(config: McpServerConfig): string | null {
 203    const cmd = getServerCommandArray(config)
 204    if (cmd) {
 205      return `stdio:${jsonStringify(cmd)}`
 206    }
 207    const url = getServerUrl(config)
 208    if (url) {
 209      return `url:${unwrapCcrProxyUrl(url)}`
 210    }
 211    return null
 212  }
 213  
 214  /**
 215   * Filter plugin MCP servers, dropping any whose signature matches a
 216   * manually-configured server or an earlier-loaded plugin server.
 217   * Manual wins over plugin; between plugins, first-loaded wins.
 218   *
 219   * Plugin servers are namespaced `plugin:name:server` so they never key-collide
 220   * with manual servers in the merge — this content-based check catches the case
 221   * where both actually launch the same underlying process/connection.
 222   */
 223  export function dedupPluginMcpServers(
 224    pluginServers: Record<string, ScopedMcpServerConfig>,
 225    manualServers: Record<string, ScopedMcpServerConfig>,
 226  ): {
 227    servers: Record<string, ScopedMcpServerConfig>
 228    suppressed: Array<{ name: string; duplicateOf: string }>
 229  } {
 230    // Map signature -> server name so we can report which server a dup matches
 231    const manualSigs = new Map<string, string>()
 232    for (const [name, config] of Object.entries(manualServers)) {
 233      const sig = getMcpServerSignature(config)
 234      if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name)
 235    }
 236  
 237    const servers: Record<string, ScopedMcpServerConfig> = {}
 238    const suppressed: Array<{ name: string; duplicateOf: string }> = []
 239    const seenPluginSigs = new Map<string, string>()
 240    for (const [name, config] of Object.entries(pluginServers)) {
 241      const sig = getMcpServerSignature(config)
 242      if (sig === null) {
 243        servers[name] = config
 244        continue
 245      }
 246      const manualDup = manualSigs.get(sig)
 247      if (manualDup !== undefined) {
 248        logForDebugging(
 249          `Suppressing plugin MCP server "${name}": duplicates manually-configured "${manualDup}"`,
 250        )
 251        suppressed.push({ name, duplicateOf: manualDup })
 252        continue
 253      }
 254      const pluginDup = seenPluginSigs.get(sig)
 255      if (pluginDup !== undefined) {
 256        logForDebugging(
 257          `Suppressing plugin MCP server "${name}": duplicates earlier plugin server "${pluginDup}"`,
 258        )
 259        suppressed.push({ name, duplicateOf: pluginDup })
 260        continue
 261      }
 262      seenPluginSigs.set(sig, name)
 263      servers[name] = config
 264    }
 265    return { servers, suppressed }
 266  }
 267  
 268  /**
 269   * Filter claude.ai connectors, dropping any whose signature matches an enabled
 270   * manually-configured server. Manual wins: a user who wrote .mcp.json or ran
 271   * `claude mcp add` expressed higher intent than a connector toggled in the web UI.
 272   *
 273   * Connector keys are `claude.ai <DisplayName>` so they never key-collide with
 274   * manual servers in the merge — this content-based check catches the case where
 275   * both point at the same underlying URL (e.g. `mcp__slack__*` and
 276   * `mcp__claude_ai_Slack__*` both hitting mcp.slack.com, ~600 chars/turn wasted).
 277   *
 278   * Only enabled manual servers count as dedup targets — a disabled manual server
 279   * mustn't suppress its connector twin, or neither runs.
 280   */
 281  export function dedupClaudeAiMcpServers(
 282    claudeAiServers: Record<string, ScopedMcpServerConfig>,
 283    manualServers: Record<string, ScopedMcpServerConfig>,
 284  ): {
 285    servers: Record<string, ScopedMcpServerConfig>
 286    suppressed: Array<{ name: string; duplicateOf: string }>
 287  } {
 288    const manualSigs = new Map<string, string>()
 289    for (const [name, config] of Object.entries(manualServers)) {
 290      if (isMcpServerDisabled(name)) continue
 291      const sig = getMcpServerSignature(config)
 292      if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name)
 293    }
 294  
 295    const servers: Record<string, ScopedMcpServerConfig> = {}
 296    const suppressed: Array<{ name: string; duplicateOf: string }> = []
 297    for (const [name, config] of Object.entries(claudeAiServers)) {
 298      const sig = getMcpServerSignature(config)
 299      const manualDup = sig !== null ? manualSigs.get(sig) : undefined
 300      if (manualDup !== undefined) {
 301        logForDebugging(
 302          `Suppressing claude.ai connector "${name}": duplicates manually-configured "${manualDup}"`,
 303        )
 304        suppressed.push({ name, duplicateOf: manualDup })
 305        continue
 306      }
 307      servers[name] = config
 308    }
 309    return { servers, suppressed }
 310  }
 311  
 312  /**
 313   * Convert a URL pattern with wildcards to a RegExp
 314   * Supports * as wildcard matching any characters
 315   * Examples:
 316   *   "https://example.com/*" matches "https://example.com/api/v1"
 317   *   "https://*.example.com/*" matches "https://api.example.com/path"
 318   *   "https://example.com:*\/*" matches any port
 319   */
 320  function urlPatternToRegex(pattern: string): RegExp {
 321    // Escape regex special characters except *
 322    const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
 323    // Replace * with regex equivalent (match any characters)
 324    const regexStr = escaped.replace(/\*/g, '.*')
 325    return new RegExp(`^${regexStr}$`)
 326  }
 327  
 328  /**
 329   * Check if a URL matches a pattern with wildcard support
 330   */
 331  function urlMatchesPattern(url: string, pattern: string): boolean {
 332    const regex = urlPatternToRegex(pattern)
 333    return regex.test(url)
 334  }
 335  
 336  /**
 337   * Get the settings to use for MCP server allowlist policy.
 338   * When allowManagedMcpServersOnly is set in policySettings, only managed settings
 339   * control which servers are allowed. Otherwise, returns merged settings.
 340   */
 341  function getMcpAllowlistSettings(): SettingsJson {
 342    if (shouldAllowManagedMcpServersOnly()) {
 343      return getSettingsForSource('policySettings') ?? {}
 344    }
 345    return getInitialSettings()
 346  }
 347  
 348  /**
 349   * Get the settings to use for MCP server denylist policy.
 350   * Denylists always merge from all sources — users can always deny servers
 351   * for themselves, even when allowManagedMcpServersOnly is set.
 352   */
 353  function getMcpDenylistSettings(): SettingsJson {
 354    return getInitialSettings()
 355  }
 356  
 357  /**
 358   * Check if an MCP server is denied by enterprise policy
 359   * Checks name-based, command-based, and URL-based restrictions
 360   * @param serverName The name of the server to check
 361   * @param config Optional server config for command/URL-based matching
 362   * @returns true if denied, false if not on denylist
 363   */
 364  function isMcpServerDenied(
 365    serverName: string,
 366    config?: McpServerConfig,
 367  ): boolean {
 368    const settings = getMcpDenylistSettings()
 369    if (!settings.deniedMcpServers) {
 370      return false // No restrictions
 371    }
 372  
 373    // Check name-based denial
 374    for (const entry of settings.deniedMcpServers) {
 375      if (isMcpServerNameEntry(entry) && entry.serverName === serverName) {
 376        return true
 377      }
 378    }
 379  
 380    // Check command-based denial (stdio servers only) and URL-based denial (remote servers only)
 381    if (config) {
 382      const serverCommand = getServerCommandArray(config)
 383      if (serverCommand) {
 384        for (const entry of settings.deniedMcpServers) {
 385          if (
 386            isMcpServerCommandEntry(entry) &&
 387            commandArraysMatch(entry.serverCommand, serverCommand)
 388          ) {
 389            return true
 390          }
 391        }
 392      }
 393  
 394      const serverUrl = getServerUrl(config)
 395      if (serverUrl) {
 396        for (const entry of settings.deniedMcpServers) {
 397          if (
 398            isMcpServerUrlEntry(entry) &&
 399            urlMatchesPattern(serverUrl, entry.serverUrl)
 400          ) {
 401            return true
 402          }
 403        }
 404      }
 405    }
 406  
 407    return false
 408  }
 409  
 410  /**
 411   * Check if an MCP server is allowed by enterprise policy
 412   * Checks name-based, command-based, and URL-based restrictions
 413   * @param serverName The name of the server to check
 414   * @param config Optional server config for command/URL-based matching
 415   * @returns true if allowed, false if blocked by policy
 416   */
 417  function isMcpServerAllowedByPolicy(
 418    serverName: string,
 419    config?: McpServerConfig,
 420  ): boolean {
 421    // Denylist takes absolute precedence
 422    if (isMcpServerDenied(serverName, config)) {
 423      return false
 424    }
 425  
 426    const settings = getMcpAllowlistSettings()
 427    if (!settings.allowedMcpServers) {
 428      return true // No allowlist restrictions (undefined)
 429    }
 430  
 431    // Empty allowlist means block all servers
 432    if (settings.allowedMcpServers.length === 0) {
 433      return false
 434    }
 435  
 436    // Check if allowlist contains any command-based or URL-based entries
 437    const hasCommandEntries = settings.allowedMcpServers.some(
 438      isMcpServerCommandEntry,
 439    )
 440    const hasUrlEntries = settings.allowedMcpServers.some(isMcpServerUrlEntry)
 441  
 442    if (config) {
 443      const serverCommand = getServerCommandArray(config)
 444      const serverUrl = getServerUrl(config)
 445  
 446      if (serverCommand) {
 447        // This is a stdio server
 448        if (hasCommandEntries) {
 449          // If ANY serverCommand entries exist, stdio servers MUST match one of them
 450          for (const entry of settings.allowedMcpServers) {
 451            if (
 452              isMcpServerCommandEntry(entry) &&
 453              commandArraysMatch(entry.serverCommand, serverCommand)
 454            ) {
 455              return true
 456            }
 457          }
 458          return false // Stdio server doesn't match any command entry
 459        } else {
 460          // No command entries, check name-based allowance
 461          for (const entry of settings.allowedMcpServers) {
 462            if (isMcpServerNameEntry(entry) && entry.serverName === serverName) {
 463              return true
 464            }
 465          }
 466          return false
 467        }
 468      } else if (serverUrl) {
 469        // This is a remote server (sse, http, ws, etc.)
 470        if (hasUrlEntries) {
 471          // If ANY serverUrl entries exist, remote servers MUST match one of them
 472          for (const entry of settings.allowedMcpServers) {
 473            if (
 474              isMcpServerUrlEntry(entry) &&
 475              urlMatchesPattern(serverUrl, entry.serverUrl)
 476            ) {
 477              return true
 478            }
 479          }
 480          return false // Remote server doesn't match any URL entry
 481        } else {
 482          // No URL entries, check name-based allowance
 483          for (const entry of settings.allowedMcpServers) {
 484            if (isMcpServerNameEntry(entry) && entry.serverName === serverName) {
 485              return true
 486            }
 487          }
 488          return false
 489        }
 490      } else {
 491        // Unknown server type - check name-based allowance only
 492        for (const entry of settings.allowedMcpServers) {
 493          if (isMcpServerNameEntry(entry) && entry.serverName === serverName) {
 494            return true
 495          }
 496        }
 497        return false
 498      }
 499    }
 500  
 501    // No config provided - check name-based allowance only
 502    for (const entry of settings.allowedMcpServers) {
 503      if (isMcpServerNameEntry(entry) && entry.serverName === serverName) {
 504        return true
 505      }
 506    }
 507    return false
 508  }
 509  
 510  /**
 511   * Filter a record of MCP server configs by managed policy (allowedMcpServers /
 512   * deniedMcpServers). Servers blocked by policy are dropped and their names
 513   * returned so callers can warn the user.
 514   *
 515   * Intended for user-controlled config entry points that bypass the policy filter
 516   * in getClaudeCodeMcpConfigs(): --mcp-config (main.tsx) and the mcp_set_servers
 517   * control message (print.ts, SDK V2 Query.setMcpServers()).
 518   *
 519   * SDK-type servers are exempt — they are SDK-managed transport placeholders,
 520   * not CLI-managed connections. The CLI never spawns a process or opens a
 521   * network connection for them; tool calls route back to the SDK via
 522   * mcp_tool_call. URL/command-based allowlist entries are meaningless for them
 523   * (no url, no command), and gating by name would silently drop them during
 524   * installPluginsAndApplyMcpInBackground's sdkMcpConfigs carry-forward.
 525   *
 526   * The generic has no type constraint because the two callsites use different
 527   * config type families: main.tsx uses ScopedMcpServerConfig (service type,
 528   * args: string[] required), print.ts uses McpServerConfigForProcessTransport
 529   * (SDK wire type, args?: string[] optional). Both are structurally compatible
 530   * with what isMcpServerAllowedByPolicy actually reads (type/url/command/args)
 531   * — the policy check only reads, never requires any field to be present.
 532   * The `as McpServerConfig` widening is safe for that reason; the downstream
 533   * checks tolerate missing/undefined fields: `config` is optional, and
 534   * `getServerCommandArray` defaults `args` to `[]` via `?? []`.
 535   */
 536  export function filterMcpServersByPolicy<T>(configs: Record<string, T>): {
 537    allowed: Record<string, T>
 538    blocked: string[]
 539  } {
 540    const allowed: Record<string, T> = {}
 541    const blocked: string[] = []
 542    for (const [name, config] of Object.entries(configs)) {
 543      const c = config as McpServerConfig
 544      if (c.type === 'sdk' || isMcpServerAllowedByPolicy(name, c)) {
 545        allowed[name] = config
 546      } else {
 547        blocked.push(name)
 548      }
 549    }
 550    return { allowed, blocked }
 551  }
 552  
 553  /**
 554   * Internal utility: Expands environment variables in an MCP server config
 555   */
 556  function expandEnvVars(config: McpServerConfig): {
 557    expanded: McpServerConfig
 558    missingVars: string[]
 559  } {
 560    const missingVars: string[] = []
 561  
 562    function expandString(str: string): string {
 563      const { expanded, missingVars: vars } = expandEnvVarsInString(str)
 564      missingVars.push(...vars)
 565      return expanded
 566    }
 567  
 568    let expanded: McpServerConfig
 569  
 570    switch (config.type) {
 571      case undefined:
 572      case 'stdio': {
 573        const stdioConfig = config as McpStdioServerConfig
 574        expanded = {
 575          ...stdioConfig,
 576          command: expandString(stdioConfig.command),
 577          args: stdioConfig.args.map(expandString),
 578          env: stdioConfig.env
 579            ? mapValues(stdioConfig.env, expandString)
 580            : undefined,
 581        }
 582        break
 583      }
 584      case 'sse':
 585      case 'http':
 586      case 'ws': {
 587        const remoteConfig = config as
 588          | McpSSEServerConfig
 589          | McpHTTPServerConfig
 590          | McpWebSocketServerConfig
 591        expanded = {
 592          ...remoteConfig,
 593          url: expandString(remoteConfig.url),
 594          headers: remoteConfig.headers
 595            ? mapValues(remoteConfig.headers, expandString)
 596            : undefined,
 597        }
 598        break
 599      }
 600      case 'sse-ide':
 601      case 'ws-ide':
 602        expanded = config
 603        break
 604      case 'sdk':
 605        expanded = config
 606        break
 607      case 'claudeai-proxy':
 608        expanded = config
 609        break
 610    }
 611  
 612    return {
 613      expanded,
 614      missingVars: [...new Set(missingVars)],
 615    }
 616  }
 617  
 618  /**
 619   * Add a new MCP server configuration
 620   * @param name The name of the server
 621   * @param config The server configuration
 622   * @param scope The configuration scope
 623   * @throws Error if name is invalid or server already exists, or if the config is invalid
 624   */
 625  export async function addMcpConfig(
 626    name: string,
 627    config: unknown,
 628    scope: ConfigScope,
 629  ): Promise<void> {
 630    if (name.match(/[^a-zA-Z0-9_-]/)) {
 631      throw new Error(
 632        `Invalid name ${name}. Names can only contain letters, numbers, hyphens, and underscores.`,
 633      )
 634    }
 635  
 636    // Block reserved server name "claude-in-chrome"
 637    if (isClaudeInChromeMCPServer(name)) {
 638      throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
 639    }
 640  
 641    if (feature('CHICAGO_MCP')) {
 642      const { isComputerUseMCPServer } = await import(
 643        '../../utils/computerUse/common.js'
 644      )
 645      if (isComputerUseMCPServer(name)) {
 646        throw new Error(`Cannot add MCP server "${name}": this name is reserved.`)
 647      }
 648    }
 649  
 650    // Block adding servers when enterprise MCP config exists (it has exclusive control)
 651    if (doesEnterpriseMcpConfigExist()) {
 652      throw new Error(
 653        `Cannot add MCP server: enterprise MCP configuration is active and has exclusive control over MCP servers`,
 654      )
 655    }
 656  
 657    // Validate config first (needed for command-based policy checks)
 658    const result = McpServerConfigSchema().safeParse(config)
 659    if (!result.success) {
 660      const formattedErrors = result.error.issues
 661        .map(err => `${err.path.join('.')}: ${err.message}`)
 662        .join(', ')
 663      throw new Error(`Invalid configuration: ${formattedErrors}`)
 664    }
 665    const validatedConfig = result.data
 666  
 667    // Check denylist (with config for command-based checks)
 668    if (isMcpServerDenied(name, validatedConfig)) {
 669      throw new Error(
 670        `Cannot add MCP server "${name}": server is explicitly blocked by enterprise policy`,
 671      )
 672    }
 673  
 674    // Check allowlist (with config for command-based checks)
 675    if (!isMcpServerAllowedByPolicy(name, validatedConfig)) {
 676      throw new Error(
 677        `Cannot add MCP server "${name}": not allowed by enterprise policy`,
 678      )
 679    }
 680  
 681    // Check if server already exists in the target scope
 682    switch (scope) {
 683      case 'project': {
 684        const { servers } = getProjectMcpConfigsFromCwd()
 685        if (servers[name]) {
 686          throw new Error(`MCP server ${name} already exists in .mcp.json`)
 687        }
 688        break
 689      }
 690      case 'user': {
 691        const globalConfig = getGlobalConfig()
 692        if (globalConfig.mcpServers?.[name]) {
 693          throw new Error(`MCP server ${name} already exists in user config`)
 694        }
 695        break
 696      }
 697      case 'local': {
 698        const projectConfig = getCurrentProjectConfig()
 699        if (projectConfig.mcpServers?.[name]) {
 700          throw new Error(`MCP server ${name} already exists in local config`)
 701        }
 702        break
 703      }
 704      case 'dynamic':
 705        throw new Error('Cannot add MCP server to scope: dynamic')
 706      case 'enterprise':
 707        throw new Error('Cannot add MCP server to scope: enterprise')
 708      case 'claudeai':
 709        throw new Error('Cannot add MCP server to scope: claudeai')
 710    }
 711  
 712    // Add based on scope
 713    switch (scope) {
 714      case 'project': {
 715        const { servers: existingServers } = getProjectMcpConfigsFromCwd()
 716  
 717        const mcpServers: Record<string, McpServerConfig> = {}
 718        for (const [serverName, serverConfig] of Object.entries(
 719          existingServers,
 720        )) {
 721          const { scope: _, ...configWithoutScope } = serverConfig
 722          mcpServers[serverName] = configWithoutScope
 723        }
 724        mcpServers[name] = validatedConfig
 725        const mcpConfig = { mcpServers }
 726  
 727        // Write back to .mcp.json
 728        try {
 729          await writeMcpjsonFile(mcpConfig)
 730        } catch (error) {
 731          throw new Error(`Failed to write to .mcp.json: ${error}`)
 732        }
 733        break
 734      }
 735  
 736      case 'user': {
 737        saveGlobalConfig(current => ({
 738          ...current,
 739          mcpServers: {
 740            ...current.mcpServers,
 741            [name]: validatedConfig,
 742          },
 743        }))
 744        break
 745      }
 746  
 747      case 'local': {
 748        saveCurrentProjectConfig(current => ({
 749          ...current,
 750          mcpServers: {
 751            ...current.mcpServers,
 752            [name]: validatedConfig,
 753          },
 754        }))
 755        break
 756      }
 757  
 758      default:
 759        throw new Error(`Cannot add MCP server to scope: ${scope}`)
 760    }
 761  }
 762  
 763  /**
 764   * Remove an MCP server configuration
 765   * @param name The name of the server to remove
 766   * @param scope The configuration scope
 767   * @throws Error if server not found in specified scope
 768   */
 769  export async function removeMcpConfig(
 770    name: string,
 771    scope: ConfigScope,
 772  ): Promise<void> {
 773    switch (scope) {
 774      case 'project': {
 775        const { servers: existingServers } = getProjectMcpConfigsFromCwd()
 776  
 777        if (!existingServers[name]) {
 778          throw new Error(`No MCP server found with name: ${name} in .mcp.json`)
 779        }
 780  
 781        // Strip scope information when writing back to .mcp.json
 782        const mcpServers: Record<string, McpServerConfig> = {}
 783        for (const [serverName, serverConfig] of Object.entries(
 784          existingServers,
 785        )) {
 786          if (serverName !== name) {
 787            const { scope: _, ...configWithoutScope } = serverConfig
 788            mcpServers[serverName] = configWithoutScope
 789          }
 790        }
 791        const mcpConfig = { mcpServers }
 792        try {
 793          await writeMcpjsonFile(mcpConfig)
 794        } catch (error) {
 795          throw new Error(`Failed to remove from .mcp.json: ${error}`)
 796        }
 797        break
 798      }
 799  
 800      case 'user': {
 801        const config = getGlobalConfig()
 802        if (!config.mcpServers?.[name]) {
 803          throw new Error(`No user-scoped MCP server found with name: ${name}`)
 804        }
 805        saveGlobalConfig(current => {
 806          const { [name]: _, ...restMcpServers } = current.mcpServers ?? {}
 807          return {
 808            ...current,
 809            mcpServers: restMcpServers,
 810          }
 811        })
 812        break
 813      }
 814  
 815      case 'local': {
 816        // Check if server exists before updating
 817        const config = getCurrentProjectConfig()
 818        if (!config.mcpServers?.[name]) {
 819          throw new Error(`No project-local MCP server found with name: ${name}`)
 820        }
 821        saveCurrentProjectConfig(current => {
 822          const { [name]: _, ...restMcpServers } = current.mcpServers ?? {}
 823          return {
 824            ...current,
 825            mcpServers: restMcpServers,
 826          }
 827        })
 828        break
 829      }
 830  
 831      default:
 832        throw new Error(`Cannot remove MCP server from scope: ${scope}`)
 833    }
 834  }
 835  
 836  /**
 837   * Get MCP configs from current directory only (no parent traversal).
 838   * Used by addMcpConfig and removeMcpConfig to modify the local .mcp.json file.
 839   * Exported for testing purposes.
 840   *
 841   * @returns Servers with scope information and any validation errors from current directory's .mcp.json
 842   */
 843  export function getProjectMcpConfigsFromCwd(): {
 844    servers: Record<string, ScopedMcpServerConfig>
 845    errors: ValidationError[]
 846  } {
 847    // Check if project source is enabled
 848    if (!isSettingSourceEnabled('projectSettings')) {
 849      return { servers: {}, errors: [] }
 850    }
 851  
 852    const mcpJsonPath = join(getCwd(), '.mcp.json')
 853  
 854    const { config, errors } = parseMcpConfigFromFilePath({
 855      filePath: mcpJsonPath,
 856      expandVars: true,
 857      scope: 'project',
 858    })
 859  
 860    // Missing .mcp.json is expected, but malformed files should report errors
 861    if (!config) {
 862      const nonMissingErrors = errors.filter(
 863        e => !e.message.startsWith('MCP config file not found'),
 864      )
 865      if (nonMissingErrors.length > 0) {
 866        logForDebugging(
 867          `MCP config errors for ${mcpJsonPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`,
 868          { level: 'error' },
 869        )
 870        return { servers: {}, errors: nonMissingErrors }
 871      }
 872      return { servers: {}, errors: [] }
 873    }
 874  
 875    return {
 876      servers: config.mcpServers
 877        ? addScopeToServers(config.mcpServers, 'project')
 878        : {},
 879      errors: errors || [],
 880    }
 881  }
 882  
 883  /**
 884   * Get all MCP configurations from a specific scope
 885   * @param scope The configuration scope
 886   * @returns Servers with scope information and any validation errors
 887   */
 888  export function getMcpConfigsByScope(
 889    scope: 'project' | 'user' | 'local' | 'enterprise',
 890  ): {
 891    servers: Record<string, ScopedMcpServerConfig>
 892    errors: ValidationError[]
 893  } {
 894    // Check if this source is enabled
 895    const sourceMap: Record<
 896      string,
 897      'projectSettings' | 'userSettings' | 'localSettings'
 898    > = {
 899      project: 'projectSettings',
 900      user: 'userSettings',
 901      local: 'localSettings',
 902    }
 903  
 904    if (scope in sourceMap && !isSettingSourceEnabled(sourceMap[scope]!)) {
 905      return { servers: {}, errors: [] }
 906    }
 907  
 908    switch (scope) {
 909      case 'project': {
 910        const allServers: Record<string, ScopedMcpServerConfig> = {}
 911        const allErrors: ValidationError[] = []
 912  
 913        // Build list of directories to check
 914        const dirs: string[] = []
 915        let currentDir = getCwd()
 916  
 917        while (currentDir !== parse(currentDir).root) {
 918          dirs.push(currentDir)
 919          currentDir = dirname(currentDir)
 920        }
 921  
 922        // Process from root downward to CWD (so closer files have higher priority)
 923        for (const dir of dirs.reverse()) {
 924          const mcpJsonPath = join(dir, '.mcp.json')
 925  
 926          const { config, errors } = parseMcpConfigFromFilePath({
 927            filePath: mcpJsonPath,
 928            expandVars: true,
 929            scope: 'project',
 930          })
 931  
 932          // Missing .mcp.json in parent directories is expected, but malformed files should report errors
 933          if (!config) {
 934            const nonMissingErrors = errors.filter(
 935              e => !e.message.startsWith('MCP config file not found'),
 936            )
 937            if (nonMissingErrors.length > 0) {
 938              logForDebugging(
 939                `MCP config errors for ${mcpJsonPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`,
 940                { level: 'error' },
 941              )
 942              allErrors.push(...nonMissingErrors)
 943            }
 944            continue
 945          }
 946  
 947          if (config.mcpServers) {
 948            // Merge servers, with files closer to CWD overriding parent configs
 949            Object.assign(allServers, addScopeToServers(config.mcpServers, scope))
 950          }
 951  
 952          if (errors.length > 0) {
 953            allErrors.push(...errors)
 954          }
 955        }
 956  
 957        return {
 958          servers: allServers,
 959          errors: allErrors,
 960        }
 961      }
 962      case 'user': {
 963        const mcpServers = getGlobalConfig().mcpServers
 964        if (!mcpServers) {
 965          return { servers: {}, errors: [] }
 966        }
 967  
 968        const { config, errors } = parseMcpConfig({
 969          configObject: { mcpServers },
 970          expandVars: true,
 971          scope: 'user',
 972        })
 973  
 974        return {
 975          servers: addScopeToServers(config?.mcpServers, scope),
 976          errors,
 977        }
 978      }
 979      case 'local': {
 980        const mcpServers = getCurrentProjectConfig().mcpServers
 981        if (!mcpServers) {
 982          return { servers: {}, errors: [] }
 983        }
 984  
 985        const { config, errors } = parseMcpConfig({
 986          configObject: { mcpServers },
 987          expandVars: true,
 988          scope: 'local',
 989        })
 990  
 991        return {
 992          servers: addScopeToServers(config?.mcpServers, scope),
 993          errors,
 994        }
 995      }
 996      case 'enterprise': {
 997        const enterpriseMcpPath = getEnterpriseMcpFilePath()
 998  
 999        const { config, errors } = parseMcpConfigFromFilePath({
1000          filePath: enterpriseMcpPath,
1001          expandVars: true,
1002          scope: 'enterprise',
1003        })
1004  
1005        // Missing enterprise config file is expected, but malformed files should report errors
1006        if (!config) {
1007          const nonMissingErrors = errors.filter(
1008            e => !e.message.startsWith('MCP config file not found'),
1009          )
1010          if (nonMissingErrors.length > 0) {
1011            logForDebugging(
1012              `Enterprise MCP config errors for ${enterpriseMcpPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`,
1013              { level: 'error' },
1014            )
1015            return { servers: {}, errors: nonMissingErrors }
1016          }
1017          return { servers: {}, errors: [] }
1018        }
1019  
1020        return {
1021          servers: addScopeToServers(config.mcpServers, scope),
1022          errors,
1023        }
1024      }
1025    }
1026  }
1027  
1028  /**
1029   * Get an MCP server configuration by name
1030   * @param name The name of the server
1031   * @returns The server configuration with scope, or undefined if not found
1032   */
1033  export function getMcpConfigByName(name: string): ScopedMcpServerConfig | null {
1034    const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise')
1035  
1036    // When MCP is locked to plugin-only, only enterprise servers are reachable
1037    // by name. User/project/local servers are blocked — same as getClaudeCodeMcpConfigs().
1038    if (isRestrictedToPluginOnly('mcp')) {
1039      return enterpriseServers[name] ?? null
1040    }
1041  
1042    const { servers: userServers } = getMcpConfigsByScope('user')
1043    const { servers: projectServers } = getMcpConfigsByScope('project')
1044    const { servers: localServers } = getMcpConfigsByScope('local')
1045  
1046    if (enterpriseServers[name]) {
1047      return enterpriseServers[name]
1048    }
1049    if (localServers[name]) {
1050      return localServers[name]
1051    }
1052    if (projectServers[name]) {
1053      return projectServers[name]
1054    }
1055    if (userServers[name]) {
1056      return userServers[name]
1057    }
1058  
1059    return null
1060  }
1061  
1062  /**
1063   * Get Claude Code MCP configurations (excludes claude.ai servers from the
1064   * returned set — they're fetched separately and merged by callers).
1065   * This is fast: only local file reads; no awaited network calls on the
1066   * critical path. The optional extraDedupTargets promise (e.g. the in-flight
1067   * claude.ai connector fetch) is awaited only after loadAllPluginsCacheOnly() completes,
1068   * so the two overlap rather than serialize.
1069   * @returns Claude Code server configurations with appropriate scopes
1070   */
1071  export async function getClaudeCodeMcpConfigs(
1072    dynamicServers: Record<string, ScopedMcpServerConfig> = {},
1073    extraDedupTargets: Promise<
1074      Record<string, ScopedMcpServerConfig>
1075    > = Promise.resolve({}),
1076  ): Promise<{
1077    servers: Record<string, ScopedMcpServerConfig>
1078    errors: PluginError[]
1079  }> {
1080    const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise')
1081  
1082    // If an enterprise mcp config exists, do not use any others; this has exclusive control over all MCP servers
1083    // (enterprise customers often do not want their users to be able to add their own MCP servers).
1084    if (doesEnterpriseMcpConfigExist()) {
1085      // Apply policy filtering to enterprise servers
1086      const filtered: Record<string, ScopedMcpServerConfig> = {}
1087  
1088      for (const [name, serverConfig] of Object.entries(enterpriseServers)) {
1089        if (!isMcpServerAllowedByPolicy(name, serverConfig)) {
1090          continue
1091        }
1092        filtered[name] = serverConfig
1093      }
1094  
1095      return { servers: filtered, errors: [] }
1096    }
1097  
1098    // Load other scopes — unless the managed policy locks MCP to plugin-only.
1099    // Unlike the enterprise-exclusive block above, this keeps plugin servers.
1100    const mcpLocked = isRestrictedToPluginOnly('mcp')
1101    const noServers: { servers: Record<string, ScopedMcpServerConfig> } = {
1102      servers: {},
1103    }
1104    const { servers: userServers } = mcpLocked
1105      ? noServers
1106      : getMcpConfigsByScope('user')
1107    const { servers: projectServers } = mcpLocked
1108      ? noServers
1109      : getMcpConfigsByScope('project')
1110    const { servers: localServers } = mcpLocked
1111      ? noServers
1112      : getMcpConfigsByScope('local')
1113  
1114    // Load plugin MCP servers
1115    const pluginMcpServers: Record<string, ScopedMcpServerConfig> = {}
1116  
1117    const pluginResult = await loadAllPluginsCacheOnly()
1118  
1119    // Collect MCP-specific errors during server loading
1120    const mcpErrors: PluginError[] = []
1121  
1122    // Log any plugin loading errors - NEVER silently fail in production
1123    if (pluginResult.errors.length > 0) {
1124      for (const error of pluginResult.errors) {
1125        // Only log as MCP error if it's actually MCP-related
1126        // Otherwise just log as debug since the plugin might not have MCP servers
1127        if (
1128          error.type === 'mcp-config-invalid' ||
1129          error.type === 'mcpb-download-failed' ||
1130          error.type === 'mcpb-extract-failed' ||
1131          error.type === 'mcpb-invalid-manifest'
1132        ) {
1133          const errorMessage = `Plugin MCP loading error - ${error.type}: ${getPluginErrorMessage(error)}`
1134          logError(new Error(errorMessage))
1135        } else {
1136          // Plugin doesn't exist or isn't available - this is common and not necessarily an error
1137          // The plugin system will handle installing it if possible
1138          const errorType = error.type
1139          logForDebugging(
1140            `Plugin not available for MCP: ${error.source} - error type: ${errorType}`,
1141          )
1142        }
1143      }
1144    }
1145  
1146    // Process enabled plugins for MCP servers in parallel
1147    const pluginServerResults = await Promise.all(
1148      pluginResult.enabled.map(plugin => getPluginMcpServers(plugin, mcpErrors)),
1149    )
1150    for (const servers of pluginServerResults) {
1151      if (servers) {
1152        Object.assign(pluginMcpServers, servers)
1153      }
1154    }
1155  
1156    // Add any MCP-specific errors from server loading to plugin errors
1157    if (mcpErrors.length > 0) {
1158      for (const error of mcpErrors) {
1159        const errorMessage = `Plugin MCP server error - ${error.type}: ${getPluginErrorMessage(error)}`
1160        logError(new Error(errorMessage))
1161      }
1162    }
1163  
1164    // Filter project servers to only include approved ones
1165    const approvedProjectServers: Record<string, ScopedMcpServerConfig> = {}
1166    for (const [name, config] of Object.entries(projectServers)) {
1167      if (getProjectMcpServerStatus(name) === 'approved') {
1168        approvedProjectServers[name] = config
1169      }
1170    }
1171  
1172    // Dedup plugin servers against manually-configured ones (and each other).
1173    // Plugin server keys are namespaced `plugin:x:y` so they never collide with
1174    // manual keys in the merge below — this content-based filter catches the case
1175    // where both would launch the same underlying process/connection.
1176    // Only servers that will actually connect are valid dedup targets — a
1177    // disabled manual server mustn't suppress a plugin server, or neither runs
1178    // (manual is skipped by name at connection time; plugin was removed here).
1179    const extraTargets = await extraDedupTargets
1180    const enabledManualServers: Record<string, ScopedMcpServerConfig> = {}
1181    for (const [name, config] of Object.entries({
1182      ...userServers,
1183      ...approvedProjectServers,
1184      ...localServers,
1185      ...dynamicServers,
1186      ...extraTargets,
1187    })) {
1188      if (
1189        !isMcpServerDisabled(name) &&
1190        isMcpServerAllowedByPolicy(name, config)
1191      ) {
1192        enabledManualServers[name] = config
1193      }
1194    }
1195    // Split off disabled/policy-blocked plugin servers so they don't win the
1196    // first-plugin-wins race against an enabled duplicate — same invariant as
1197    // above. They're merged back after dedup so they still appear in /mcp
1198    // (policy filtering at the end of this function drops blocked ones).
1199    const enabledPluginServers: Record<string, ScopedMcpServerConfig> = {}
1200    const disabledPluginServers: Record<string, ScopedMcpServerConfig> = {}
1201    for (const [name, config] of Object.entries(pluginMcpServers)) {
1202      if (
1203        isMcpServerDisabled(name) ||
1204        !isMcpServerAllowedByPolicy(name, config)
1205      ) {
1206        disabledPluginServers[name] = config
1207      } else {
1208        enabledPluginServers[name] = config
1209      }
1210    }
1211    const { servers: dedupedPluginServers, suppressed } = dedupPluginMcpServers(
1212      enabledPluginServers,
1213      enabledManualServers,
1214    )
1215    Object.assign(dedupedPluginServers, disabledPluginServers)
1216    // Surface suppressions in /plugin UI. Pushed AFTER the logError loop above
1217    // so these don't go to the error log — they're informational, not errors.
1218    for (const { name, duplicateOf } of suppressed) {
1219      // name is "plugin:${pluginName}:${serverName}" from addPluginScopeToServers
1220      const parts = name.split(':')
1221      if (parts[0] !== 'plugin' || parts.length < 3) continue
1222      mcpErrors.push({
1223        type: 'mcp-server-suppressed-duplicate',
1224        source: name,
1225        plugin: parts[1]!,
1226        serverName: parts.slice(2).join(':'),
1227        duplicateOf,
1228      })
1229    }
1230  
1231    // Merge in order of precedence: plugin < user < project < local
1232    const configs = Object.assign(
1233      {},
1234      dedupedPluginServers,
1235      userServers,
1236      approvedProjectServers,
1237      localServers,
1238    )
1239  
1240    // Apply policy filtering to merged configs
1241    const filtered: Record<string, ScopedMcpServerConfig> = {}
1242  
1243    for (const [name, serverConfig] of Object.entries(configs)) {
1244      if (!isMcpServerAllowedByPolicy(name, serverConfig as McpServerConfig)) {
1245        continue
1246      }
1247      filtered[name] = serverConfig as ScopedMcpServerConfig
1248    }
1249  
1250    return { servers: filtered, errors: mcpErrors }
1251  }
1252  
1253  /**
1254   * Get all MCP configurations across all scopes, including claude.ai servers.
1255   * This may be slow due to network calls - use getClaudeCodeMcpConfigs() for fast startup.
1256   * @returns All server configurations with appropriate scopes
1257   */
1258  export async function getAllMcpConfigs(): Promise<{
1259    servers: Record<string, ScopedMcpServerConfig>
1260    errors: PluginError[]
1261  }> {
1262    // In enterprise mode, don't load claude.ai servers (enterprise has exclusive control)
1263    if (doesEnterpriseMcpConfigExist()) {
1264      return getClaudeCodeMcpConfigs()
1265    }
1266  
1267    // Kick off the claude.ai fetch before getClaudeCodeMcpConfigs so it overlaps
1268    // with loadAllPluginsCacheOnly() inside. Memoized — the awaited call below is a cache hit.
1269    const claudeaiPromise = fetchClaudeAIMcpConfigsIfEligible()
1270    const { servers: claudeCodeServers, errors } = await getClaudeCodeMcpConfigs(
1271      {},
1272      claudeaiPromise,
1273    )
1274    const { allowed: claudeaiMcpServers } = filterMcpServersByPolicy(
1275      await claudeaiPromise,
1276    )
1277  
1278    // Suppress claude.ai connectors that duplicate an enabled manual server.
1279    // Keys never collide (`slack` vs `claude.ai Slack`) so the merge below
1280    // won't catch this — need content-based dedup by URL signature.
1281    const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers(
1282      claudeaiMcpServers,
1283      claudeCodeServers,
1284    )
1285  
1286    // Merge with claude.ai having lowest precedence
1287    const servers = Object.assign({}, dedupedClaudeAi, claudeCodeServers)
1288  
1289    return { servers, errors }
1290  }
1291  
1292  /**
1293   * Parse and validate an MCP configuration object
1294   * @param params Parsing parameters
1295   * @returns Validated configuration with any errors
1296   */
1297  export function parseMcpConfig(params: {
1298    configObject: unknown
1299    expandVars: boolean
1300    scope: ConfigScope
1301    filePath?: string
1302  }): {
1303    config: McpJsonConfig | null
1304    errors: ValidationError[]
1305  } {
1306    const { configObject, expandVars, scope, filePath } = params
1307    const schemaResult = McpJsonConfigSchema().safeParse(configObject)
1308    if (!schemaResult.success) {
1309      return {
1310        config: null,
1311        errors: schemaResult.error.issues.map(issue => ({
1312          ...(filePath && { file: filePath }),
1313          path: issue.path.join('.'),
1314          message: 'Does not adhere to MCP server configuration schema',
1315          mcpErrorMetadata: {
1316            scope,
1317            severity: 'fatal',
1318          },
1319        })),
1320      }
1321    }
1322  
1323    // Validate each server and expand variables if requested
1324    const errors: ValidationError[] = []
1325    const validatedServers: Record<string, McpServerConfig> = {}
1326  
1327    for (const [name, config] of Object.entries(schemaResult.data.mcpServers)) {
1328      let configToCheck = config
1329  
1330      if (expandVars) {
1331        const { expanded, missingVars } = expandEnvVars(config)
1332  
1333        if (missingVars.length > 0) {
1334          errors.push({
1335            ...(filePath && { file: filePath }),
1336            path: `mcpServers.${name}`,
1337            message: `Missing environment variables: ${missingVars.join(', ')}`,
1338            suggestion: `Set the following environment variables: ${missingVars.join(', ')}`,
1339            mcpErrorMetadata: {
1340              scope,
1341              serverName: name,
1342              severity: 'warning',
1343            },
1344          })
1345        }
1346  
1347        configToCheck = expanded
1348      }
1349  
1350      // Check for Windows-specific npx usage without cmd wrapper
1351      if (
1352        getPlatform() === 'windows' &&
1353        (!configToCheck.type || configToCheck.type === 'stdio') &&
1354        (configToCheck.command === 'npx' ||
1355          configToCheck.command.endsWith('\\npx') ||
1356          configToCheck.command.endsWith('/npx'))
1357      ) {
1358        errors.push({
1359          ...(filePath && { file: filePath }),
1360          path: `mcpServers.${name}`,
1361          message: `Windows requires 'cmd /c' wrapper to execute npx`,
1362          suggestion: `Change command to "cmd" with args ["/c", "npx", ...]. See: https://code.claude.com/docs/en/mcp#configure-mcp-servers`,
1363          mcpErrorMetadata: {
1364            scope,
1365            serverName: name,
1366            severity: 'warning',
1367          },
1368        })
1369      }
1370  
1371      validatedServers[name] = configToCheck
1372    }
1373    return {
1374      config: { mcpServers: validatedServers },
1375      errors,
1376    }
1377  }
1378  
1379  /**
1380   * Parse and validate an MCP configuration from a file path
1381   * @param params Parsing parameters
1382   * @returns Validated configuration with any errors
1383   */
1384  export function parseMcpConfigFromFilePath(params: {
1385    filePath: string
1386    expandVars: boolean
1387    scope: ConfigScope
1388  }): {
1389    config: McpJsonConfig | null
1390    errors: ValidationError[]
1391  } {
1392    const { filePath, expandVars, scope } = params
1393    const fs = getFsImplementation()
1394  
1395    let configContent: string
1396    try {
1397      configContent = fs.readFileSync(filePath, { encoding: 'utf8' })
1398    } catch (error: unknown) {
1399      const code = getErrnoCode(error)
1400      if (code === 'ENOENT') {
1401        return {
1402          config: null,
1403          errors: [
1404            {
1405              file: filePath,
1406              path: '',
1407              message: `MCP config file not found: ${filePath}`,
1408              suggestion: 'Check that the file path is correct',
1409              mcpErrorMetadata: {
1410                scope,
1411                severity: 'fatal',
1412              },
1413            },
1414          ],
1415        }
1416      }
1417      logForDebugging(
1418        `MCP config read error for ${filePath} (scope=${scope}): ${error}`,
1419        { level: 'error' },
1420      )
1421      return {
1422        config: null,
1423        errors: [
1424          {
1425            file: filePath,
1426            path: '',
1427            message: `Failed to read file: ${error}`,
1428            suggestion: 'Check file permissions and ensure the file exists',
1429            mcpErrorMetadata: {
1430              scope,
1431              severity: 'fatal',
1432            },
1433          },
1434        ],
1435      }
1436    }
1437  
1438    const parsedJson = safeParseJSON(configContent)
1439  
1440    if (!parsedJson) {
1441      logForDebugging(
1442        `MCP config is not valid JSON: ${filePath} (scope=${scope}, length=${configContent.length}, first100=${jsonStringify(configContent.slice(0, 100))})`,
1443        { level: 'error' },
1444      )
1445      return {
1446        config: null,
1447        errors: [
1448          {
1449            file: filePath,
1450            path: '',
1451            message: `MCP config is not a valid JSON`,
1452            suggestion: 'Fix the JSON syntax errors in the file',
1453            mcpErrorMetadata: {
1454              scope,
1455              severity: 'fatal',
1456            },
1457          },
1458        ],
1459      }
1460    }
1461  
1462    return parseMcpConfig({
1463      configObject: parsedJson,
1464      expandVars,
1465      scope,
1466      filePath,
1467    })
1468  }
1469  
1470  export const doesEnterpriseMcpConfigExist = memoize((): boolean => {
1471    const { config } = parseMcpConfigFromFilePath({
1472      filePath: getEnterpriseMcpFilePath(),
1473      expandVars: true,
1474      scope: 'enterprise',
1475    })
1476    return config !== null
1477  })
1478  
1479  /**
1480   * Check if MCP allowlist policy should only come from managed settings.
1481   * This is true when policySettings has allowManagedMcpServersOnly: true.
1482   * When enabled, allowedMcpServers is read exclusively from managed settings.
1483   * Users can still add their own MCP servers and deny servers via deniedMcpServers.
1484   */
1485  export function shouldAllowManagedMcpServersOnly(): boolean {
1486    return (
1487      getSettingsForSource('policySettings')?.allowManagedMcpServersOnly === true
1488    )
1489  }
1490  
1491  /**
1492   * Check if all MCP servers in a config are allowed with enterprise MCP config.
1493   */
1494  export function areMcpConfigsAllowedWithEnterpriseMcpConfig(
1495    configs: Record<string, ScopedMcpServerConfig>,
1496  ): boolean {
1497    // NOTE: While all SDK MCP servers should be safe from a security perspective, we are still discussing
1498    // what the best way to do this is. In the meantime, we are limiting this to claude-vscode for now to
1499    // unbreak the VSCode extension for certain enterprise customers who have enterprise MCP config enabled.
1500    // https://anthropic.slack.com/archives/C093UA0KLD7/p1764975463670109
1501    return Object.values(configs).every(
1502      c => c.type === 'sdk' && c.name === 'claude-vscode',
1503    )
1504  }
1505  
1506  /**
1507   * Built-in MCP server that defaults to disabled. Unlike user-configured servers
1508   * (opt-out via disabledMcpServers), this requires explicit opt-in via
1509   * enabledMcpServers. Shows up in /mcp as disabled until the user enables it.
1510   */
1511  /* eslint-disable @typescript-eslint/no-require-imports */
1512  const DEFAULT_DISABLED_BUILTIN = feature('CHICAGO_MCP')
1513    ? (
1514        require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js')
1515      ).COMPUTER_USE_MCP_SERVER_NAME
1516    : null
1517  /* eslint-enable @typescript-eslint/no-require-imports */
1518  
1519  function isDefaultDisabledBuiltin(name: string): boolean {
1520    return DEFAULT_DISABLED_BUILTIN !== null && name === DEFAULT_DISABLED_BUILTIN
1521  }
1522  
1523  /**
1524   * Check if an MCP server is disabled
1525   * @param name The name of the server
1526   * @returns true if the server is disabled
1527   */
1528  export function isMcpServerDisabled(name: string): boolean {
1529    const projectConfig = getCurrentProjectConfig()
1530    if (isDefaultDisabledBuiltin(name)) {
1531      const enabledServers = projectConfig.enabledMcpServers || []
1532      return !enabledServers.includes(name)
1533    }
1534    const disabledServers = projectConfig.disabledMcpServers || []
1535    return disabledServers.includes(name)
1536  }
1537  
1538  function toggleMembership(
1539    list: string[],
1540    name: string,
1541    shouldContain: boolean,
1542  ): string[] {
1543    const contains = list.includes(name)
1544    if (contains === shouldContain) return list
1545    return shouldContain ? [...list, name] : list.filter(s => s !== name)
1546  }
1547  
1548  /**
1549   * Enable or disable an MCP server
1550   * @param name The name of the server
1551   * @param enabled Whether the server should be enabled
1552   */
1553  export function setMcpServerEnabled(name: string, enabled: boolean): void {
1554    const isBuiltinStateChange =
1555      isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled
1556  
1557    saveCurrentProjectConfig(current => {
1558      if (isDefaultDisabledBuiltin(name)) {
1559        const prev = current.enabledMcpServers || []
1560        const next = toggleMembership(prev, name, enabled)
1561        if (next === prev) return current
1562        return { ...current, enabledMcpServers: next }
1563      }
1564  
1565      const prev = current.disabledMcpServers || []
1566      const next = toggleMembership(prev, name, !enabled)
1567      if (next === prev) return current
1568      return { ...current, disabledMcpServers: next }
1569    })
1570  
1571    if (isBuiltinStateChange) {
1572      logEvent('tengu_builtin_mcp_toggle', {
1573        serverName:
1574          name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
1575        enabled,
1576      })
1577    }
1578  }