| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578 |
- 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<string, McpServerConfig> | undefined,
- scope: ConfigScope,
- ): Record<string, ScopedMcpServerConfig> {
- if (!servers) {
- return {}
- }
- const scopedServers: Record<string, ScopedMcpServerConfig> = {}
- 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<void> {
- 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<string, ScopedMcpServerConfig>,
- manualServers: Record<string, ScopedMcpServerConfig>,
- ): {
- servers: Record<string, ScopedMcpServerConfig>
- suppressed: Array<{ name: string; duplicateOf: string }>
- } {
- // Map signature -> server name so we can report which server a dup matches
- const manualSigs = new Map<string, string>()
- for (const [name, config] of Object.entries(manualServers)) {
- const sig = getMcpServerSignature(config)
- if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name)
- }
- const servers: Record<string, ScopedMcpServerConfig> = {}
- const suppressed: Array<{ name: string; duplicateOf: string }> = []
- const seenPluginSigs = new Map<string, string>()
- 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 <DisplayName>` 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<string, ScopedMcpServerConfig>,
- manualServers: Record<string, ScopedMcpServerConfig>,
- ): {
- servers: Record<string, ScopedMcpServerConfig>
- suppressed: Array<{ name: string; duplicateOf: string }>
- } {
- const manualSigs = new Map<string, string>()
- 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<string, ScopedMcpServerConfig> = {}
- 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<T>(configs: Record<string, T>): {
- allowed: Record<string, T>
- blocked: string[]
- } {
- const allowed: Record<string, T> = {}
- 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<void> {
- 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<string, McpServerConfig> = {}
- 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<void> {
- 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<string, McpServerConfig> = {}
- 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<string, ScopedMcpServerConfig>
- 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<string, ScopedMcpServerConfig>
- 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<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig> = {},
- extraDedupTargets: Promise<
- Record<string, ScopedMcpServerConfig>
- > = Promise.resolve({}),
- ): Promise<{
- servers: Record<string, ScopedMcpServerConfig>
- 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<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig> } = {
- 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<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig> = {}
- const disabledPluginServers: Record<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig> = {}
- 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<string, ScopedMcpServerConfig>
- 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<string, McpServerConfig> = {}
- 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)
- 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 for ${filePath} (scope=${scope}): ${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: ${filePath} (scope=${scope}, length=${configContent.length}, first100=${jsonStringify(configContent.slice(0, 100))})`,
- { 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<string, ScopedMcpServerConfig>,
- ): 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,
- })
- }
- }
|