π File detail
services/mcp/config.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes getEnterpriseMcpFilePath, unwrapCcrProxyUrl, getMcpServerSignature, dedupPluginMcpServers, and dedupClaudeAiMcpServers (and more) β mainly functions, hooks, or classes. Dependencies touch bun:bundle, Node filesystem, lodash-es, and Node path helpers. It composes internal code from types, utils, analytics, claudeai, and envExpansion (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
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'
π€ Exports (heuristic)
getEnterpriseMcpFilePathunwrapCcrProxyUrlgetMcpServerSignaturededupPluginMcpServersdedupClaudeAiMcpServersfilterMcpServersByPolicyaddMcpConfigremoveMcpConfiggetProjectMcpConfigsFromCwdgetMcpConfigsByScopegetMcpConfigByNamegetClaudeCodeMcpConfigsgetAllMcpConfigsparseMcpConfigparseMcpConfigFromFilePathdoesEnterpriseMcpConfigExistshouldAllowManagedMcpServersOnlyareMcpConfigsAllowedWithEnterpriseMcpConfigisMcpServerDisabledsetMcpServerEnabled
π External import roots
Package roots from from "β¦" (relative paths omitted).
bun:bundlefslodash-espathsrc
π₯οΈ Source preview
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,
})
}
}