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 }