import { feature } from 'bun:bundle' import { chmod, open, rename, stat, unlink } from 'fs/promises' import mapValues from 'lodash-es/mapValues.js' import memoize from 'lodash-es/memoize.js' import { dirname, join, parse } from 'path' import { getPlatform } from 'src/utils/platform.js' import type { PluginError } from '../../types/plugin.js' import { getPluginErrorMessage } from '../../types/plugin.js' import { isClaudeInChromeMCPServer } from '../../utils/claudeInChrome/common.js' import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig, saveGlobalConfig, } from '../../utils/config.js' import { getCwd } from '../../utils/cwd.js' import { logForDebugging } from '../../utils/debug.js' import { getErrnoCode } from '../../utils/errors.js' import { getFsImplementation } from '../../utils/fsOperations.js' import { safeParseJSON } from '../../utils/json.js' import { logError } from '../../utils/log.js' import { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' import { isSettingSourceEnabled } from '../../utils/settings/constants.js' import { getManagedFilePath } from '../../utils/settings/managedPath.js' import { isRestrictedToPluginOnly } from '../../utils/settings/pluginOnlyPolicy.js' import { getInitialSettings, getSettingsForSource, } from '../../utils/settings/settings.js' import { isMcpServerCommandEntry, isMcpServerNameEntry, isMcpServerUrlEntry, type SettingsJson, } from '../../utils/settings/types.js' import type { ValidationError } from '../../utils/settings/validation.js' import { jsonStringify } from '../../utils/slowOperations.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from '../analytics/index.js' import { fetchClaudeAIMcpConfigsIfEligible } from './claudeai.js' import { expandEnvVarsInString } from './envExpansion.js' import { type ConfigScope, type McpHTTPServerConfig, type McpJsonConfig, McpJsonConfigSchema, type McpServerConfig, McpServerConfigSchema, type McpSSEServerConfig, type McpStdioServerConfig, type McpWebSocketServerConfig, type ScopedMcpServerConfig, } from './types.js' import { getProjectMcpServerStatus } from './utils.js' /** * Get the path to the managed MCP configuration file */ export function getEnterpriseMcpFilePath(): string { return join(getManagedFilePath(), 'managed-mcp.json') } /** * Internal utility: Add scope to server configs */ function addScopeToServers( servers: Record | undefined, scope: ConfigScope, ): Record { if (!servers) { return {} } const scopedServers: Record = {} for (const [name, config] of Object.entries(servers)) { scopedServers[name] = { ...config, scope } } return scopedServers } /** * Internal utility: Write MCP config to .mcp.json file. * Preserves file permissions and flushes to disk before rename. * Uses the original path for rename (does not follow symlinks). */ async function writeMcpjsonFile(config: McpJsonConfig): Promise { const mcpJsonPath = join(getCwd(), '.mcp.json') // Read existing file permissions to preserve them let existingMode: number | undefined try { const stats = await stat(mcpJsonPath) existingMode = stats.mode } catch (e: unknown) { const code = getErrnoCode(e) if (code !== 'ENOENT') { throw e } // File doesn't exist yet -- no permissions to preserve } // Write to temp file, flush to disk, then atomic rename const tempPath = `${mcpJsonPath}.tmp.${process.pid}.${Date.now()}` const handle = await open(tempPath, 'w', existingMode ?? 0o644) try { await handle.writeFile(jsonStringify(config, null, 2), { encoding: 'utf8', }) await handle.datasync() } finally { await handle.close() } try { // Restore original file permissions on the temp file before rename if (existingMode !== undefined) { await chmod(tempPath, existingMode) } await rename(tempPath, mcpJsonPath) } catch (e: unknown) { // Clean up temp file on failure try { await unlink(tempPath) } catch { // Best-effort cleanup } throw e } } /** * Extract command array from server config (stdio servers only) * Returns null for non-stdio servers */ function getServerCommandArray(config: McpServerConfig): string[] | null { // Non-stdio servers don't have commands if (config.type !== undefined && config.type !== 'stdio') { return null } const stdioConfig = config as McpStdioServerConfig return [stdioConfig.command, ...(stdioConfig.args ?? [])] } /** * Check if two command arrays match exactly */ function commandArraysMatch(a: string[], b: string[]): boolean { if (a.length !== b.length) { return false } return a.every((val, idx) => val === b[idx]) } /** * Extract URL from server config (remote servers only) * Returns null for stdio/sdk servers */ function getServerUrl(config: McpServerConfig): string | null { return 'url' in config ? config.url : null } /** * CCR proxy URL path markers. In remote sessions, claude.ai connectors arrive * via --mcp-config with URLs rewritten to route through the CCR/session-ingress * SHTTP proxy. The original vendor URL is preserved in the mcp_url query param * so the proxy knows where to forward. See api-go/ccr/internal/ccrshared/ * mcp_url_rewriter.go and api-go/ccr/internal/mcpproxy/proxy.go. */ const CCR_PROXY_PATH_MARKERS = [ '/v2/session_ingress/shttp/mcp/', '/v2/ccr-sessions/', ] /** * If the URL is a CCR proxy URL, extract the original vendor URL from the * mcp_url query parameter. Otherwise return the URL unchanged. This lets * signature-based dedup match a plugin's raw vendor URL against a connector's * rewritten proxy URL when both point at the same MCP server. */ export function unwrapCcrProxyUrl(url: string): string { if (!CCR_PROXY_PATH_MARKERS.some(m => url.includes(m))) { return url } try { const parsed = new URL(url) const original = parsed.searchParams.get('mcp_url') return original || url } catch { return url } } /** * Compute a dedup signature for an MCP server config. * Two configs with the same signature are considered "the same server" for * plugin deduplication. Ignores env (plugins always inject CLAUDE_PLUGIN_ROOT) * and headers (same URL = same server regardless of auth). * Returns null only for configs with neither command nor url (sdk type). */ export function getMcpServerSignature(config: McpServerConfig): string | null { const cmd = getServerCommandArray(config) if (cmd) { return `stdio:${jsonStringify(cmd)}` } const url = getServerUrl(config) if (url) { return `url:${unwrapCcrProxyUrl(url)}` } return null } /** * Filter plugin MCP servers, dropping any whose signature matches a * manually-configured server or an earlier-loaded plugin server. * Manual wins over plugin; between plugins, first-loaded wins. * * Plugin servers are namespaced `plugin:name:server` so they never key-collide * with manual servers in the merge — this content-based check catches the case * where both actually launch the same underlying process/connection. */ export function dedupPluginMcpServers( pluginServers: Record, manualServers: Record, ): { servers: Record suppressed: Array<{ name: string; duplicateOf: string }> } { // Map signature -> server name so we can report which server a dup matches const manualSigs = new Map() for (const [name, config] of Object.entries(manualServers)) { const sig = getMcpServerSignature(config) if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name) } const servers: Record = {} const suppressed: Array<{ name: string; duplicateOf: string }> = [] const seenPluginSigs = new Map() for (const [name, config] of Object.entries(pluginServers)) { const sig = getMcpServerSignature(config) if (sig === null) { servers[name] = config continue } const manualDup = manualSigs.get(sig) if (manualDup !== undefined) { logForDebugging( `Suppressing plugin MCP server "${name}": duplicates manually-configured "${manualDup}"`, ) suppressed.push({ name, duplicateOf: manualDup }) continue } const pluginDup = seenPluginSigs.get(sig) if (pluginDup !== undefined) { logForDebugging( `Suppressing plugin MCP server "${name}": duplicates earlier plugin server "${pluginDup}"`, ) suppressed.push({ name, duplicateOf: pluginDup }) continue } seenPluginSigs.set(sig, name) servers[name] = config } return { servers, suppressed } } /** * Filter claude.ai connectors, dropping any whose signature matches an enabled * manually-configured server. Manual wins: a user who wrote .mcp.json or ran * `claude mcp add` expressed higher intent than a connector toggled in the web UI. * * Connector keys are `claude.ai ` so they never key-collide with * manual servers in the merge — this content-based check catches the case where * both point at the same underlying URL (e.g. `mcp__slack__*` and * `mcp__claude_ai_Slack__*` both hitting mcp.slack.com, ~600 chars/turn wasted). * * Only enabled manual servers count as dedup targets — a disabled manual server * mustn't suppress its connector twin, or neither runs. */ export function dedupClaudeAiMcpServers( claudeAiServers: Record, manualServers: Record, ): { servers: Record suppressed: Array<{ name: string; duplicateOf: string }> } { const manualSigs = new Map() for (const [name, config] of Object.entries(manualServers)) { if (isMcpServerDisabled(name)) continue const sig = getMcpServerSignature(config) if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name) } const servers: Record = {} const suppressed: Array<{ name: string; duplicateOf: string }> = [] for (const [name, config] of Object.entries(claudeAiServers)) { const sig = getMcpServerSignature(config) const manualDup = sig !== null ? manualSigs.get(sig) : undefined if (manualDup !== undefined) { logForDebugging( `Suppressing claude.ai connector "${name}": duplicates manually-configured "${manualDup}"`, ) suppressed.push({ name, duplicateOf: manualDup }) continue } servers[name] = config } return { servers, suppressed } } /** * Convert a URL pattern with wildcards to a RegExp * Supports * as wildcard matching any characters * Examples: * "https://example.com/*" matches "https://example.com/api/v1" * "https://*.example.com/*" matches "https://api.example.com/path" * "https://example.com:*\/*" matches any port */ function urlPatternToRegex(pattern: string): RegExp { // Escape regex special characters except * const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Replace * with regex equivalent (match any characters) const regexStr = escaped.replace(/\*/g, '.*') return new RegExp(`^${regexStr}$`) } /** * Check if a URL matches a pattern with wildcard support */ function urlMatchesPattern(url: string, pattern: string): boolean { const regex = urlPatternToRegex(pattern) return regex.test(url) } /** * Get the settings to use for MCP server allowlist policy. * When allowManagedMcpServersOnly is set in policySettings, only managed settings * control which servers are allowed. Otherwise, returns merged settings. */ function getMcpAllowlistSettings(): SettingsJson { if (shouldAllowManagedMcpServersOnly()) { return getSettingsForSource('policySettings') ?? {} } return getInitialSettings() } /** * Get the settings to use for MCP server denylist policy. * Denylists always merge from all sources — users can always deny servers * for themselves, even when allowManagedMcpServersOnly is set. */ function getMcpDenylistSettings(): SettingsJson { return getInitialSettings() } /** * Check if an MCP server is denied by enterprise policy * Checks name-based, command-based, and URL-based restrictions * @param serverName The name of the server to check * @param config Optional server config for command/URL-based matching * @returns true if denied, false if not on denylist */ function isMcpServerDenied( serverName: string, config?: McpServerConfig, ): boolean { const settings = getMcpDenylistSettings() if (!settings.deniedMcpServers) { return false // No restrictions } // Check name-based denial for (const entry of settings.deniedMcpServers) { if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { return true } } // Check command-based denial (stdio servers only) and URL-based denial (remote servers only) if (config) { const serverCommand = getServerCommandArray(config) if (serverCommand) { for (const entry of settings.deniedMcpServers) { if ( isMcpServerCommandEntry(entry) && commandArraysMatch(entry.serverCommand, serverCommand) ) { return true } } } const serverUrl = getServerUrl(config) if (serverUrl) { for (const entry of settings.deniedMcpServers) { if ( isMcpServerUrlEntry(entry) && urlMatchesPattern(serverUrl, entry.serverUrl) ) { return true } } } } return false } /** * Check if an MCP server is allowed by enterprise policy * Checks name-based, command-based, and URL-based restrictions * @param serverName The name of the server to check * @param config Optional server config for command/URL-based matching * @returns true if allowed, false if blocked by policy */ function isMcpServerAllowedByPolicy( serverName: string, config?: McpServerConfig, ): boolean { // Denylist takes absolute precedence if (isMcpServerDenied(serverName, config)) { return false } const settings = getMcpAllowlistSettings() if (!settings.allowedMcpServers) { return true // No allowlist restrictions (undefined) } // Empty allowlist means block all servers if (settings.allowedMcpServers.length === 0) { return false } // Check if allowlist contains any command-based or URL-based entries const hasCommandEntries = settings.allowedMcpServers.some( isMcpServerCommandEntry, ) const hasUrlEntries = settings.allowedMcpServers.some(isMcpServerUrlEntry) if (config) { const serverCommand = getServerCommandArray(config) const serverUrl = getServerUrl(config) if (serverCommand) { // This is a stdio server if (hasCommandEntries) { // If ANY serverCommand entries exist, stdio servers MUST match one of them for (const entry of settings.allowedMcpServers) { if ( isMcpServerCommandEntry(entry) && commandArraysMatch(entry.serverCommand, serverCommand) ) { return true } } return false // Stdio server doesn't match any command entry } else { // No command entries, check name-based allowance for (const entry of settings.allowedMcpServers) { if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { return true } } return false } } else if (serverUrl) { // This is a remote server (sse, http, ws, etc.) if (hasUrlEntries) { // If ANY serverUrl entries exist, remote servers MUST match one of them for (const entry of settings.allowedMcpServers) { if ( isMcpServerUrlEntry(entry) && urlMatchesPattern(serverUrl, entry.serverUrl) ) { return true } } return false // Remote server doesn't match any URL entry } else { // No URL entries, check name-based allowance for (const entry of settings.allowedMcpServers) { if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { return true } } return false } } else { // Unknown server type - check name-based allowance only for (const entry of settings.allowedMcpServers) { if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { return true } } return false } } // No config provided - check name-based allowance only for (const entry of settings.allowedMcpServers) { if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { return true } } return false } /** * Filter a record of MCP server configs by managed policy (allowedMcpServers / * deniedMcpServers). Servers blocked by policy are dropped and their names * returned so callers can warn the user. * * Intended for user-controlled config entry points that bypass the policy filter * in getClaudeCodeMcpConfigs(): --mcp-config (main.tsx) and the mcp_set_servers * control message (print.ts, SDK V2 Query.setMcpServers()). * * SDK-type servers are exempt — they are SDK-managed transport placeholders, * not CLI-managed connections. The CLI never spawns a process or opens a * network connection for them; tool calls route back to the SDK via * mcp_tool_call. URL/command-based allowlist entries are meaningless for them * (no url, no command), and gating by name would silently drop them during * installPluginsAndApplyMcpInBackground's sdkMcpConfigs carry-forward. * * The generic has no type constraint because the two callsites use different * config type families: main.tsx uses ScopedMcpServerConfig (service type, * args: string[] required), print.ts uses McpServerConfigForProcessTransport * (SDK wire type, args?: string[] optional). Both are structurally compatible * with what isMcpServerAllowedByPolicy actually reads (type/url/command/args) * — the policy check only reads, never requires any field to be present. * The `as McpServerConfig` widening is safe for that reason; the downstream * checks tolerate missing/undefined fields: `config` is optional, and * `getServerCommandArray` defaults `args` to `[]` via `?? []`. */ export function filterMcpServersByPolicy(configs: Record): { allowed: Record blocked: string[] } { const allowed: Record = {} const blocked: string[] = [] for (const [name, config] of Object.entries(configs)) { const c = config as McpServerConfig if (c.type === 'sdk' || isMcpServerAllowedByPolicy(name, c)) { allowed[name] = config } else { blocked.push(name) } } return { allowed, blocked } } /** * Internal utility: Expands environment variables in an MCP server config */ function expandEnvVars(config: McpServerConfig): { expanded: McpServerConfig missingVars: string[] } { const missingVars: string[] = [] function expandString(str: string): string { const { expanded, missingVars: vars } = expandEnvVarsInString(str) missingVars.push(...vars) return expanded } let expanded: McpServerConfig switch (config.type) { case undefined: case 'stdio': { const stdioConfig = config as McpStdioServerConfig expanded = { ...stdioConfig, command: expandString(stdioConfig.command), args: stdioConfig.args.map(expandString), env: stdioConfig.env ? mapValues(stdioConfig.env, expandString) : undefined, } break } case 'sse': case 'http': case 'ws': { const remoteConfig = config as | McpSSEServerConfig | McpHTTPServerConfig | McpWebSocketServerConfig expanded = { ...remoteConfig, url: expandString(remoteConfig.url), headers: remoteConfig.headers ? mapValues(remoteConfig.headers, expandString) : undefined, } break } case 'sse-ide': case 'ws-ide': expanded = config break case 'sdk': expanded = config break case 'claudeai-proxy': expanded = config break } return { expanded, missingVars: [...new Set(missingVars)], } } /** * Add a new MCP server configuration * @param name The name of the server * @param config The server configuration * @param scope The configuration scope * @throws Error if name is invalid or server already exists, or if the config is invalid */ export async function addMcpConfig( name: string, config: unknown, scope: ConfigScope, ): Promise { if (name.match(/[^a-zA-Z0-9_-]/)) { throw new Error( `Invalid name ${name}. Names can only contain letters, numbers, hyphens, and underscores.`, ) } // Block reserved server name "claude-in-chrome" if (isClaudeInChromeMCPServer(name)) { throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) } if (feature('CHICAGO_MCP')) { const { isComputerUseMCPServer } = await import( '../../utils/computerUse/common.js' ) if (isComputerUseMCPServer(name)) { throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) } } // Block adding servers when enterprise MCP config exists (it has exclusive control) if (doesEnterpriseMcpConfigExist()) { throw new Error( `Cannot add MCP server: enterprise MCP configuration is active and has exclusive control over MCP servers`, ) } // Validate config first (needed for command-based policy checks) const result = McpServerConfigSchema().safeParse(config) if (!result.success) { const formattedErrors = result.error.issues .map(err => `${err.path.join('.')}: ${err.message}`) .join(', ') throw new Error(`Invalid configuration: ${formattedErrors}`) } const validatedConfig = result.data // Check denylist (with config for command-based checks) if (isMcpServerDenied(name, validatedConfig)) { throw new Error( `Cannot add MCP server "${name}": server is explicitly blocked by enterprise policy`, ) } // Check allowlist (with config for command-based checks) if (!isMcpServerAllowedByPolicy(name, validatedConfig)) { throw new Error( `Cannot add MCP server "${name}": not allowed by enterprise policy`, ) } // Check if server already exists in the target scope switch (scope) { case 'project': { const { servers } = getProjectMcpConfigsFromCwd() if (servers[name]) { throw new Error(`MCP server ${name} already exists in .mcp.json`) } break } case 'user': { const globalConfig = getGlobalConfig() if (globalConfig.mcpServers?.[name]) { throw new Error(`MCP server ${name} already exists in user config`) } break } case 'local': { const projectConfig = getCurrentProjectConfig() if (projectConfig.mcpServers?.[name]) { throw new Error(`MCP server ${name} already exists in local config`) } break } case 'dynamic': throw new Error('Cannot add MCP server to scope: dynamic') case 'enterprise': throw new Error('Cannot add MCP server to scope: enterprise') case 'claudeai': throw new Error('Cannot add MCP server to scope: claudeai') } // Add based on scope switch (scope) { case 'project': { const { servers: existingServers } = getProjectMcpConfigsFromCwd() const mcpServers: Record = {} for (const [serverName, serverConfig] of Object.entries( existingServers, )) { const { scope: _, ...configWithoutScope } = serverConfig mcpServers[serverName] = configWithoutScope } mcpServers[name] = validatedConfig const mcpConfig = { mcpServers } // Write back to .mcp.json try { await writeMcpjsonFile(mcpConfig) } catch (error) { throw new Error(`Failed to write to .mcp.json: ${error}`) } break } case 'user': { saveGlobalConfig(current => ({ ...current, mcpServers: { ...current.mcpServers, [name]: validatedConfig, }, })) break } case 'local': { saveCurrentProjectConfig(current => ({ ...current, mcpServers: { ...current.mcpServers, [name]: validatedConfig, }, })) break } default: throw new Error(`Cannot add MCP server to scope: ${scope}`) } } /** * Remove an MCP server configuration * @param name The name of the server to remove * @param scope The configuration scope * @throws Error if server not found in specified scope */ export async function removeMcpConfig( name: string, scope: ConfigScope, ): Promise { switch (scope) { case 'project': { const { servers: existingServers } = getProjectMcpConfigsFromCwd() if (!existingServers[name]) { throw new Error(`No MCP server found with name: ${name} in .mcp.json`) } // Strip scope information when writing back to .mcp.json const mcpServers: Record = {} for (const [serverName, serverConfig] of Object.entries( existingServers, )) { if (serverName !== name) { const { scope: _, ...configWithoutScope } = serverConfig mcpServers[serverName] = configWithoutScope } } const mcpConfig = { mcpServers } try { await writeMcpjsonFile(mcpConfig) } catch (error) { throw new Error(`Failed to remove from .mcp.json: ${error}`) } break } case 'user': { const config = getGlobalConfig() if (!config.mcpServers?.[name]) { throw new Error(`No user-scoped MCP server found with name: ${name}`) } saveGlobalConfig(current => { const { [name]: _, ...restMcpServers } = current.mcpServers ?? {} return { ...current, mcpServers: restMcpServers, } }) break } case 'local': { // Check if server exists before updating const config = getCurrentProjectConfig() if (!config.mcpServers?.[name]) { throw new Error(`No project-local MCP server found with name: ${name}`) } saveCurrentProjectConfig(current => { const { [name]: _, ...restMcpServers } = current.mcpServers ?? {} return { ...current, mcpServers: restMcpServers, } }) break } default: throw new Error(`Cannot remove MCP server from scope: ${scope}`) } } /** * Get MCP configs from current directory only (no parent traversal). * Used by addMcpConfig and removeMcpConfig to modify the local .mcp.json file. * Exported for testing purposes. * * @returns Servers with scope information and any validation errors from current directory's .mcp.json */ export function getProjectMcpConfigsFromCwd(): { servers: Record errors: ValidationError[] } { // Check if project source is enabled if (!isSettingSourceEnabled('projectSettings')) { return { servers: {}, errors: [] } } const mcpJsonPath = join(getCwd(), '.mcp.json') const { config, errors } = parseMcpConfigFromFilePath({ filePath: mcpJsonPath, expandVars: true, scope: 'project', }) // Missing .mcp.json is expected, but malformed files should report errors if (!config) { const nonMissingErrors = errors.filter( e => !e.message.startsWith('MCP config file not found'), ) if (nonMissingErrors.length > 0) { logForDebugging( `MCP config errors for ${mcpJsonPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`, { level: 'error' }, ) return { servers: {}, errors: nonMissingErrors } } return { servers: {}, errors: [] } } return { servers: config.mcpServers ? addScopeToServers(config.mcpServers, 'project') : {}, errors: errors || [], } } /** * Get all MCP configurations from a specific scope * @param scope The configuration scope * @returns Servers with scope information and any validation errors */ export function getMcpConfigsByScope( scope: 'project' | 'user' | 'local' | 'enterprise', ): { servers: Record errors: ValidationError[] } { // Check if this source is enabled const sourceMap: Record< string, 'projectSettings' | 'userSettings' | 'localSettings' > = { project: 'projectSettings', user: 'userSettings', local: 'localSettings', } if (scope in sourceMap && !isSettingSourceEnabled(sourceMap[scope]!)) { return { servers: {}, errors: [] } } switch (scope) { case 'project': { const allServers: Record = {} const allErrors: ValidationError[] = [] // Build list of directories to check const dirs: string[] = [] let currentDir = getCwd() while (currentDir !== parse(currentDir).root) { dirs.push(currentDir) currentDir = dirname(currentDir) } // Process from root downward to CWD (so closer files have higher priority) for (const dir of dirs.reverse()) { const mcpJsonPath = join(dir, '.mcp.json') const { config, errors } = parseMcpConfigFromFilePath({ filePath: mcpJsonPath, expandVars: true, scope: 'project', }) // Missing .mcp.json in parent directories is expected, but malformed files should report errors if (!config) { const nonMissingErrors = errors.filter( e => !e.message.startsWith('MCP config file not found'), ) if (nonMissingErrors.length > 0) { logForDebugging( `MCP config errors for ${mcpJsonPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`, { level: 'error' }, ) allErrors.push(...nonMissingErrors) } continue } if (config.mcpServers) { // Merge servers, with files closer to CWD overriding parent configs Object.assign(allServers, addScopeToServers(config.mcpServers, scope)) } if (errors.length > 0) { allErrors.push(...errors) } } return { servers: allServers, errors: allErrors, } } case 'user': { const mcpServers = getGlobalConfig().mcpServers if (!mcpServers) { return { servers: {}, errors: [] } } const { config, errors } = parseMcpConfig({ configObject: { mcpServers }, expandVars: true, scope: 'user', }) return { servers: addScopeToServers(config?.mcpServers, scope), errors, } } case 'local': { const mcpServers = getCurrentProjectConfig().mcpServers if (!mcpServers) { return { servers: {}, errors: [] } } const { config, errors } = parseMcpConfig({ configObject: { mcpServers }, expandVars: true, scope: 'local', }) return { servers: addScopeToServers(config?.mcpServers, scope), errors, } } case 'enterprise': { const enterpriseMcpPath = getEnterpriseMcpFilePath() const { config, errors } = parseMcpConfigFromFilePath({ filePath: enterpriseMcpPath, expandVars: true, scope: 'enterprise', }) // Missing enterprise config file is expected, but malformed files should report errors if (!config) { const nonMissingErrors = errors.filter( e => !e.message.startsWith('MCP config file not found'), ) if (nonMissingErrors.length > 0) { logForDebugging( `Enterprise MCP config errors for ${enterpriseMcpPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`, { level: 'error' }, ) return { servers: {}, errors: nonMissingErrors } } return { servers: {}, errors: [] } } return { servers: addScopeToServers(config.mcpServers, scope), errors, } } } } /** * Get an MCP server configuration by name * @param name The name of the server * @returns The server configuration with scope, or undefined if not found */ export function getMcpConfigByName(name: string): ScopedMcpServerConfig | null { const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise') // When MCP is locked to plugin-only, only enterprise servers are reachable // by name. User/project/local servers are blocked — same as getClaudeCodeMcpConfigs(). if (isRestrictedToPluginOnly('mcp')) { return enterpriseServers[name] ?? null } const { servers: userServers } = getMcpConfigsByScope('user') const { servers: projectServers } = getMcpConfigsByScope('project') const { servers: localServers } = getMcpConfigsByScope('local') if (enterpriseServers[name]) { return enterpriseServers[name] } if (localServers[name]) { return localServers[name] } if (projectServers[name]) { return projectServers[name] } if (userServers[name]) { return userServers[name] } return null } /** * Get Claude Code MCP configurations (excludes claude.ai servers from the * returned set — they're fetched separately and merged by callers). * This is fast: only local file reads; no awaited network calls on the * critical path. The optional extraDedupTargets promise (e.g. the in-flight * claude.ai connector fetch) is awaited only after loadAllPluginsCacheOnly() completes, * so the two overlap rather than serialize. * @returns Claude Code server configurations with appropriate scopes */ export async function getClaudeCodeMcpConfigs( dynamicServers: Record = {}, extraDedupTargets: Promise< Record > = Promise.resolve({}), ): Promise<{ servers: Record errors: PluginError[] }> { const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise') // If an enterprise mcp config exists, do not use any others; this has exclusive control over all MCP servers // (enterprise customers often do not want their users to be able to add their own MCP servers). if (doesEnterpriseMcpConfigExist()) { // Apply policy filtering to enterprise servers const filtered: Record = {} for (const [name, serverConfig] of Object.entries(enterpriseServers)) { if (!isMcpServerAllowedByPolicy(name, serverConfig)) { continue } filtered[name] = serverConfig } return { servers: filtered, errors: [] } } // Load other scopes — unless the managed policy locks MCP to plugin-only. // Unlike the enterprise-exclusive block above, this keeps plugin servers. const mcpLocked = isRestrictedToPluginOnly('mcp') const noServers: { servers: Record } = { servers: {}, } const { servers: userServers } = mcpLocked ? noServers : getMcpConfigsByScope('user') const { servers: projectServers } = mcpLocked ? noServers : getMcpConfigsByScope('project') const { servers: localServers } = mcpLocked ? noServers : getMcpConfigsByScope('local') // Load plugin MCP servers const pluginMcpServers: Record = {} const pluginResult = await loadAllPluginsCacheOnly() // Collect MCP-specific errors during server loading const mcpErrors: PluginError[] = [] // Log any plugin loading errors - NEVER silently fail in production if (pluginResult.errors.length > 0) { for (const error of pluginResult.errors) { // Only log as MCP error if it's actually MCP-related // Otherwise just log as debug since the plugin might not have MCP servers if ( error.type === 'mcp-config-invalid' || error.type === 'mcpb-download-failed' || error.type === 'mcpb-extract-failed' || error.type === 'mcpb-invalid-manifest' ) { const errorMessage = `Plugin MCP loading error - ${error.type}: ${getPluginErrorMessage(error)}` logError(new Error(errorMessage)) } else { // Plugin doesn't exist or isn't available - this is common and not necessarily an error // The plugin system will handle installing it if possible const errorType = error.type logForDebugging( `Plugin not available for MCP: ${error.source} - error type: ${errorType}`, ) } } } // Process enabled plugins for MCP servers in parallel const pluginServerResults = await Promise.all( pluginResult.enabled.map(plugin => getPluginMcpServers(plugin, mcpErrors)), ) for (const servers of pluginServerResults) { if (servers) { Object.assign(pluginMcpServers, servers) } } // Add any MCP-specific errors from server loading to plugin errors if (mcpErrors.length > 0) { for (const error of mcpErrors) { const errorMessage = `Plugin MCP server error - ${error.type}: ${getPluginErrorMessage(error)}` logError(new Error(errorMessage)) } } // Filter project servers to only include approved ones const approvedProjectServers: Record = {} for (const [name, config] of Object.entries(projectServers)) { if (getProjectMcpServerStatus(name) === 'approved') { approvedProjectServers[name] = config } } // Dedup plugin servers against manually-configured ones (and each other). // Plugin server keys are namespaced `plugin:x:y` so they never collide with // manual keys in the merge below — this content-based filter catches the case // where both would launch the same underlying process/connection. // Only servers that will actually connect are valid dedup targets — a // disabled manual server mustn't suppress a plugin server, or neither runs // (manual is skipped by name at connection time; plugin was removed here). const extraTargets = await extraDedupTargets const enabledManualServers: Record = {} for (const [name, config] of Object.entries({ ...userServers, ...approvedProjectServers, ...localServers, ...dynamicServers, ...extraTargets, })) { if ( !isMcpServerDisabled(name) && isMcpServerAllowedByPolicy(name, config) ) { enabledManualServers[name] = config } } // Split off disabled/policy-blocked plugin servers so they don't win the // first-plugin-wins race against an enabled duplicate — same invariant as // above. They're merged back after dedup so they still appear in /mcp // (policy filtering at the end of this function drops blocked ones). const enabledPluginServers: Record = {} const disabledPluginServers: Record = {} for (const [name, config] of Object.entries(pluginMcpServers)) { if ( isMcpServerDisabled(name) || !isMcpServerAllowedByPolicy(name, config) ) { disabledPluginServers[name] = config } else { enabledPluginServers[name] = config } } const { servers: dedupedPluginServers, suppressed } = dedupPluginMcpServers( enabledPluginServers, enabledManualServers, ) Object.assign(dedupedPluginServers, disabledPluginServers) // Surface suppressions in /plugin UI. Pushed AFTER the logError loop above // so these don't go to the error log — they're informational, not errors. for (const { name, duplicateOf } of suppressed) { // name is "plugin:${pluginName}:${serverName}" from addPluginScopeToServers const parts = name.split(':') if (parts[0] !== 'plugin' || parts.length < 3) continue mcpErrors.push({ type: 'mcp-server-suppressed-duplicate', source: name, plugin: parts[1]!, serverName: parts.slice(2).join(':'), duplicateOf, }) } // Merge in order of precedence: plugin < user < project < local const configs = Object.assign( {}, dedupedPluginServers, userServers, approvedProjectServers, localServers, ) // Apply policy filtering to merged configs const filtered: Record = {} for (const [name, serverConfig] of Object.entries(configs)) { if (!isMcpServerAllowedByPolicy(name, serverConfig as McpServerConfig)) { continue } filtered[name] = serverConfig as ScopedMcpServerConfig } return { servers: filtered, errors: mcpErrors } } /** * Get all MCP configurations across all scopes, including claude.ai servers. * This may be slow due to network calls - use getClaudeCodeMcpConfigs() for fast startup. * @returns All server configurations with appropriate scopes */ export async function getAllMcpConfigs(): Promise<{ servers: Record errors: PluginError[] }> { // In enterprise mode, don't load claude.ai servers (enterprise has exclusive control) if (doesEnterpriseMcpConfigExist()) { return getClaudeCodeMcpConfigs() } // Kick off the claude.ai fetch before getClaudeCodeMcpConfigs so it overlaps // with loadAllPluginsCacheOnly() inside. Memoized — the awaited call below is a cache hit. const claudeaiPromise = fetchClaudeAIMcpConfigsIfEligible() const { servers: claudeCodeServers, errors } = await getClaudeCodeMcpConfigs( {}, claudeaiPromise, ) const { allowed: claudeaiMcpServers } = filterMcpServersByPolicy( await claudeaiPromise, ) // Suppress claude.ai connectors that duplicate an enabled manual server. // Keys never collide (`slack` vs `claude.ai Slack`) so the merge below // won't catch this — need content-based dedup by URL signature. const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers( claudeaiMcpServers, claudeCodeServers, ) // Merge with claude.ai having lowest precedence const servers = Object.assign({}, dedupedClaudeAi, claudeCodeServers) return { servers, errors } } /** * Parse and validate an MCP configuration object * @param params Parsing parameters * @returns Validated configuration with any errors */ export function parseMcpConfig(params: { configObject: unknown expandVars: boolean scope: ConfigScope filePath?: string }): { config: McpJsonConfig | null errors: ValidationError[] } { const { configObject, expandVars, scope, filePath } = params const schemaResult = McpJsonConfigSchema().safeParse(configObject) if (!schemaResult.success) { return { config: null, errors: schemaResult.error.issues.map(issue => ({ ...(filePath && { file: filePath }), path: issue.path.join('.'), message: 'Does not adhere to MCP server configuration schema', mcpErrorMetadata: { scope, severity: 'fatal', }, })), } } // Validate each server and expand variables if requested const errors: ValidationError[] = [] const validatedServers: Record = {} for (const [name, config] of Object.entries(schemaResult.data.mcpServers)) { let configToCheck = config if (expandVars) { const { expanded, missingVars } = expandEnvVars(config) if (missingVars.length > 0) { errors.push({ ...(filePath && { file: filePath }), path: `mcpServers.${name}`, message: `Missing environment variables: ${missingVars.join(', ')}`, suggestion: `Set the following environment variables: ${missingVars.join(', ')}`, mcpErrorMetadata: { scope, serverName: name, severity: 'warning', }, }) } configToCheck = expanded } // Check for Windows-specific npx usage without cmd wrapper if ( getPlatform() === 'windows' && (!configToCheck.type || configToCheck.type === 'stdio') && (configToCheck.command === 'npx' || configToCheck.command.endsWith('\\npx') || configToCheck.command.endsWith('/npx')) ) { errors.push({ ...(filePath && { file: filePath }), path: `mcpServers.${name}`, message: `Windows requires 'cmd /c' wrapper to execute npx`, suggestion: `Change command to "cmd" with args ["/c", "npx", ...]. See: https://code.claude.com/docs/en/mcp#configure-mcp-servers`, mcpErrorMetadata: { scope, serverName: name, severity: 'warning', }, }) } validatedServers[name] = configToCheck } return { config: { mcpServers: validatedServers }, errors, } } /** * Parse and validate an MCP configuration from a file path * @param params Parsing parameters * @returns Validated configuration with any errors */ export function parseMcpConfigFromFilePath(params: { filePath: string expandVars: boolean scope: ConfigScope }): { config: McpJsonConfig | null errors: ValidationError[] } { const { filePath, expandVars, scope } = params const fs = getFsImplementation() let configContent: string try { configContent = fs.readFileSync(filePath, { encoding: 'utf8' }) } catch (error: unknown) { const code = getErrnoCode(error) const fileName = parse(filePath).base if (code === 'ENOENT') { return { config: null, errors: [ { file: filePath, path: '', message: `MCP config file not found: ${filePath}`, suggestion: 'Check that the file path is correct', mcpErrorMetadata: { scope, severity: 'fatal', }, }, ], } } logForDebugging( `MCP config read error (scope=${scope}, file=${fileName}, errno=${code ?? 'none'}, errorType=${error instanceof Error ? error.name : typeof error})`, { level: 'error' }, ) return { config: null, errors: [ { file: filePath, path: '', message: `Failed to read file: ${error}`, suggestion: 'Check file permissions and ensure the file exists', mcpErrorMetadata: { scope, severity: 'fatal', }, }, ], } } const parsedJson = safeParseJSON(configContent) if (!parsedJson) { logForDebugging( `MCP config is not valid JSON (scope=${scope}, file=${parse(filePath).base}, length=${configContent.length})`, { level: 'error' }, ) return { config: null, errors: [ { file: filePath, path: '', message: `MCP config is not a valid JSON`, suggestion: 'Fix the JSON syntax errors in the file', mcpErrorMetadata: { scope, severity: 'fatal', }, }, ], } } return parseMcpConfig({ configObject: parsedJson, expandVars, scope, filePath, }) } export const doesEnterpriseMcpConfigExist = memoize((): boolean => { const { config } = parseMcpConfigFromFilePath({ filePath: getEnterpriseMcpFilePath(), expandVars: true, scope: 'enterprise', }) return config !== null }) /** * Check if MCP allowlist policy should only come from managed settings. * This is true when policySettings has allowManagedMcpServersOnly: true. * When enabled, allowedMcpServers is read exclusively from managed settings. * Users can still add their own MCP servers and deny servers via deniedMcpServers. */ export function shouldAllowManagedMcpServersOnly(): boolean { return ( getSettingsForSource('policySettings')?.allowManagedMcpServersOnly === true ) } /** * Check if all MCP servers in a config are allowed with enterprise MCP config. */ export function areMcpConfigsAllowedWithEnterpriseMcpConfig( configs: Record, ): boolean { // NOTE: While all SDK MCP servers should be safe from a security perspective, we are still discussing // what the best way to do this is. In the meantime, we are limiting this to claude-vscode for now to // unbreak the VSCode extension for certain enterprise customers who have enterprise MCP config enabled. // https://anthropic.slack.com/archives/C093UA0KLD7/p1764975463670109 return Object.values(configs).every( c => c.type === 'sdk' && c.name === 'claude-vscode', ) } /** * Built-in MCP server that defaults to disabled. Unlike user-configured servers * (opt-out via disabledMcpServers), this requires explicit opt-in via * enabledMcpServers. Shows up in /mcp as disabled until the user enables it. */ /* eslint-disable @typescript-eslint/no-require-imports */ const DEFAULT_DISABLED_BUILTIN = feature('CHICAGO_MCP') ? ( require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') ).COMPUTER_USE_MCP_SERVER_NAME : null /* eslint-enable @typescript-eslint/no-require-imports */ function isDefaultDisabledBuiltin(name: string): boolean { return DEFAULT_DISABLED_BUILTIN !== null && name === DEFAULT_DISABLED_BUILTIN } /** * Check if an MCP server is disabled * @param name The name of the server * @returns true if the server is disabled */ export function isMcpServerDisabled(name: string): boolean { const projectConfig = getCurrentProjectConfig() if (isDefaultDisabledBuiltin(name)) { const enabledServers = projectConfig.enabledMcpServers || [] return !enabledServers.includes(name) } const disabledServers = projectConfig.disabledMcpServers || [] return disabledServers.includes(name) } function toggleMembership( list: string[], name: string, shouldContain: boolean, ): string[] { const contains = list.includes(name) if (contains === shouldContain) return list return shouldContain ? [...list, name] : list.filter(s => s !== name) } /** * Enable or disable an MCP server * @param name The name of the server * @param enabled Whether the server should be enabled */ export function setMcpServerEnabled(name: string, enabled: boolean): void { const isBuiltinStateChange = isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled saveCurrentProjectConfig(current => { if (isDefaultDisabledBuiltin(name)) { const prev = current.enabledMcpServers || [] const next = toggleMembership(prev, name, enabled) if (next === prev) return current return { ...current, enabledMcpServers: next } } const prev = current.disabledMcpServers || [] const next = toggleMembership(prev, name, !enabled) if (next === prev) return current return { ...current, disabledMcpServers: next } }) if (isBuiltinStateChange) { logEvent('tengu_builtin_mcp_toggle', { serverName: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, enabled, }) } }