1580 lines
50 KiB
TypeScript
1580 lines
50 KiB
TypeScript
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)
|
|
const fileName = parse(filePath).base
|
|
if (code === 'ENOENT') {
|
|
return {
|
|
config: null,
|
|
errors: [
|
|
{
|
|
file: filePath,
|
|
path: '',
|
|
message: `MCP config file not found: ${filePath}`,
|
|
suggestion: 'Check that the file path is correct',
|
|
mcpErrorMetadata: {
|
|
scope,
|
|
severity: 'fatal',
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
logForDebugging(
|
|
`MCP config read error (scope=${scope}, file=${fileName}, errno=${code ?? 'none'}, errorType=${error instanceof Error ? error.name : typeof error})`,
|
|
{ level: 'error' },
|
|
)
|
|
return {
|
|
config: null,
|
|
errors: [
|
|
{
|
|
file: filePath,
|
|
path: '',
|
|
message: `Failed to read file: ${error}`,
|
|
suggestion: 'Check file permissions and ensure the file exists',
|
|
mcpErrorMetadata: {
|
|
scope,
|
|
severity: 'fatal',
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
const parsedJson = safeParseJSON(configContent)
|
|
|
|
if (!parsedJson) {
|
|
logForDebugging(
|
|
`MCP config is not valid JSON (scope=${scope}, file=${parse(filePath).base}, length=${configContent.length})`,
|
|
{ level: 'error' },
|
|
)
|
|
return {
|
|
config: null,
|
|
errors: [
|
|
{
|
|
file: filePath,
|
|
path: '',
|
|
message: `MCP config is not a valid JSON`,
|
|
suggestion: 'Fix the JSON syntax errors in the file',
|
|
mcpErrorMetadata: {
|
|
scope,
|
|
severity: 'fatal',
|
|
},
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
return parseMcpConfig({
|
|
configObject: parsedJson,
|
|
expandVars,
|
|
scope,
|
|
filePath,
|
|
})
|
|
}
|
|
|
|
export const doesEnterpriseMcpConfigExist = memoize((): boolean => {
|
|
const { config } = parseMcpConfigFromFilePath({
|
|
filePath: getEnterpriseMcpFilePath(),
|
|
expandVars: true,
|
|
scope: 'enterprise',
|
|
})
|
|
return config !== null
|
|
})
|
|
|
|
/**
|
|
* Check if MCP allowlist policy should only come from managed settings.
|
|
* This is true when policySettings has allowManagedMcpServersOnly: true.
|
|
* When enabled, allowedMcpServers is read exclusively from managed settings.
|
|
* Users can still add their own MCP servers and deny servers via deniedMcpServers.
|
|
*/
|
|
export function shouldAllowManagedMcpServersOnly(): boolean {
|
|
return (
|
|
getSettingsForSource('policySettings')?.allowManagedMcpServersOnly === true
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Check if all MCP servers in a config are allowed with enterprise MCP config.
|
|
*/
|
|
export function areMcpConfigsAllowedWithEnterpriseMcpConfig(
|
|
configs: Record<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,
|
|
})
|
|
}
|
|
}
|