config.ts 50 KB

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