fix: align desktop MCP scopes with CLI

Desktop MCP settings were using the process startup directory for local
project config and treated new custom servers as user-global. The desktop
API now carries the active cwd into local MCP reads, writes, and enablement
state, and the settings page exposes the same local/project/user scopes that
the CLI supports.

Constraint: Claude Code MCP scope semantics are local, project, and user.
Constraint: Desktop sessions can switch projects inside one long-lived server process.
Rejected: Only clear getProjectPathForConfig cache | still leaves request-scoped desktop API calls tied to getOriginalCwd
Confidence: high
Scope-risk: moderate
Directive: Do not route MCP local config or disabledMcpServers through ambient original cwd in desktop APIs.
Tested: bun test src/server/__tests__/mcp.test.ts
Tested: cd desktop && bun run test -- mcpSettings.test.tsx
Tested: bun run check:server
Tested: bun run check:desktop
Not-tested: Live provider smoke; the change is config/control-channel scoped.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-24 15:55:38 +08:00
parent 562523aea6
commit 3d2b219eda
11 changed files with 303 additions and 41 deletions

View File

@ -49,7 +49,7 @@ describe('McpSettings', () => {
})
})
it('loads only global MCP servers on mount', () => {
it('loads MCP servers for the active project on mount', () => {
const fetchServers = vi.fn()
useMcpStore.setState({ fetchServers })
@ -219,7 +219,59 @@ describe('McpSettings', () => {
fireEvent.click(screen.getByRole('switch'))
})
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project')
expect(toggleServer).toHaveBeenCalledWith(server, '/workspace/project', 'session-1')
})
it('creates local MCP servers by default to match the CLI', async () => {
const createdServer = {
name: 'context7',
scope: 'local',
transport: 'stdio',
enabled: true,
status: 'checking' as const,
statusLabel: 'Checking',
configLocation: '/workspace/project/.claude.json',
summary: 'npx @upstash/context7-mcp',
canEdit: true,
canRemove: true,
canReconnect: true,
canToggle: true,
projectPath: '/workspace/project',
config: { type: 'stdio' as const, command: 'npx', args: ['@upstash/context7-mcp'], env: {} },
}
const createServer = vi.fn().mockResolvedValue(createdServer)
useMcpStore.setState({ createServer })
render(<McpSettings />)
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /add server/i }))
})
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'context7' } })
fireEvent.change(screen.getByLabelText(/Command to launch/), { target: { value: 'npx' } })
fireEvent.change(screen.getByPlaceholderText('chrome-devtools-mcp@latest'), {
target: { value: '@upstash/context7-mcp' },
})
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
})
expect(createServer).toHaveBeenCalledWith(
'context7',
{
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['@upstash/context7-mcp'],
env: {},
},
},
'/workspace/project',
)
})
it('shows reconnecting status immediately in the detail view', async () => {

View File

@ -33,8 +33,14 @@ export const mcpApi = {
return api.delete<{ ok: true }>(`/api/mcp/${encodeURIComponent(name)}?${query.toString()}`)
},
toggle: (name: string, cwd?: string) => {
return api.post<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/toggle`, cwd ? { cwd } : {})
toggle: (name: string, cwd?: string, sessionId?: string) => {
return api.post<{ server: McpServerRecord }>(
`/api/mcp/${encodeURIComponent(name)}/toggle`,
{
...(cwd ? { cwd } : {}),
...(sessionId ? { sessionId } : {}),
},
)
},
reconnect: (name: string, cwd?: string) => {

View File

@ -467,7 +467,7 @@ export const en = {
// Settings > MCP
'settings.mcp.title': 'MCP servers',
'settings.mcp.description': 'Connect external tools and data sources for Claude Code. Add, edit, reconnect, and disable servers without leaving the desktop app.',
'settings.mcp.description': 'Connect external tools and data sources for Claude Code. Local, project, and user scopes follow the same behavior as the CLI.',
'settings.mcp.addServer': 'Add server',
'settings.mcp.empty': 'No MCP servers configured yet',
'settings.mcp.emptyHint': 'Add a custom stdio, HTTP, or SSE MCP server to start extending tool access.',
@ -486,7 +486,8 @@ export const en = {
'settings.mcp.scope.claudeai': 'Claude.ai',
'settings.mcp.scope.managed': 'Managed',
'settings.mcp.transport.http': 'Streamable HTTP',
'settings.mcp.globalOnlyHint': 'This page manages only user-global MCP servers for speed. Project-specific MCP will move into the chat slash-command experience.',
'settings.mcp.globalOnlyHint': 'Choose Local for one project, Project for `.mcp.json`, or User for all projects.',
'settings.mcp.currentProjectHint': 'Selected project: {path}',
'settings.mcp.form.back': 'Back to servers',
'settings.mcp.form.createTitle': 'Connect to a custom MCP',
'settings.mcp.form.createHint': 'Set up a custom MCP server with the fields supported by Claude Code today.',

View File

@ -469,7 +469,7 @@ export const zh: Record<TranslationKey, string> = {
// Settings > MCP
'settings.mcp.title': 'MCP 服务',
'settings.mcp.description': '在桌面端直接管理外部工具与数据源。你可以添加、编辑、重连或禁用 MCP 服务。',
'settings.mcp.description': '在桌面端直接管理外部工具与数据源。Local、Project、User 三种范围与 CLI 保持一致。',
'settings.mcp.addServer': '添加服务',
'settings.mcp.empty': '还没有配置 MCP 服务',
'settings.mcp.emptyHint': '先添加一个自定义的 STDIO、HTTP 或 SSE MCP 服务。',
@ -488,7 +488,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.mcp.scope.claudeai': 'Claude.ai',
'settings.mcp.scope.managed': '受管控',
'settings.mcp.transport.http': 'Streamable HTTP',
'settings.mcp.globalOnlyHint': '这个页面只管理全局用户 MCP以保证速度和清晰度。项目级 MCP 将放到聊天页的斜杠命令体验里。',
'settings.mcp.globalOnlyHint': '选择项目私有、项目共享或全局用户范围。',
'settings.mcp.currentProjectHint': '当前项目:{path}',
'settings.mcp.form.back': '返回服务列表',
'settings.mcp.form.createTitle': '连接自定义 MCP',
'settings.mcp.form.createHint': '按当前 Claude Code 支持的字段添加一个自定义 MCP 服务。',

View File

@ -6,7 +6,7 @@ import { useTranslation } from '../i18n'
import { useUIStore } from '../stores/uiStore'
import { useMcpStore } from '../stores/mcpStore'
import { useSessionStore } from '../stores/sessionStore'
import type { McpServerRecord, McpUpsertPayload } from '../types/mcp'
import type { McpServerRecord, McpUpsertPayload, McpWritableScope } from '../types/mcp'
type EditorMode =
| { type: 'list' }
@ -29,6 +29,7 @@ type KeyValueRow = {
type McpDraft = {
name: string
scope: McpWritableScope
transport: TransportKind
command: string
args: StringRow[]
@ -61,6 +62,8 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
'dynamic',
]
const WRITABLE_SCOPES: McpWritableScope[] = ['local', 'project', 'user']
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
connected: 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-border)]',
checking: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]',
@ -85,6 +88,7 @@ function createKeyValueRow(key = '', value = ''): KeyValueRow {
function createEmptyDraft(): McpDraft {
return {
name: '',
scope: 'local',
transport: 'stdio',
command: '',
args: [createStringRow('')],
@ -97,6 +101,10 @@ function createEmptyDraft(): McpDraft {
}
}
function asWritableScope(scope: string): McpWritableScope {
return scope === 'project' || scope === 'user' ? scope : 'local'
}
function isStdioConfig(config: McpServerRecord['config']): config is Extract<McpServerRecord['config'], { type: 'stdio' }> {
return config.type === 'stdio'
}
@ -108,6 +116,7 @@ function isRemoteConfig(config: McpServerRecord['config']): config is Extract<Mc
function draftFromServer(server: McpServerRecord): McpDraft {
const base = createEmptyDraft()
base.name = server.name
base.scope = asWritableScope(server.scope)
if (isStdioConfig(server.config)) {
return {
@ -155,7 +164,7 @@ function rowsToList(rows: StringRow[]) {
function buildPayload(draft: McpDraft): McpUpsertPayload {
if (draft.transport === 'stdio') {
return {
scope: 'user',
scope: draft.scope,
config: {
type: 'stdio',
command: draft.command.trim(),
@ -170,7 +179,7 @@ function buildPayload(draft: McpDraft): McpUpsertPayload {
const oauthClientId = draft.oauthClientId.trim()
return {
scope: 'user',
scope: draft.scope,
config: {
type: draft.transport,
url: draft.url.trim(),
@ -506,7 +515,7 @@ export function McpSettings() {
const handleToggle = async (server: McpServerRecord) => {
setBusyServerName(server.name)
try {
const updated = await toggleServer(server, resolveOperationCwd(server))
const updated = await toggleServer(server, resolveOperationCwd(server), activeSessionId ?? undefined)
addToast({
type: 'success',
message: updated.enabled ? t('settings.mcp.toast.enabled', { name: server.name }) : t('settings.mcp.toast.disabled', { name: server.name }),
@ -800,9 +809,33 @@ export function McpSettings() {
<div className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">
{t('settings.mcp.form.scope')}
</div>
<p className="text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.mcp.globalOnlyHint')}
</p>
<div className="grid gap-2 md:grid-cols-3">
{WRITABLE_SCOPES.map((scope) => {
const active = draft.scope === scope
return (
<button
key={scope}
type="button"
onClick={() => setDraftField('scope', scope)}
className={`rounded-[var(--radius-md)] border p-3 text-left transition-colors ${
active
? 'border-[var(--color-border-focus)] bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="block text-sm font-semibold">{t(`settings.mcp.scope.${scope}`)}</span>
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">
{t(`settings.mcp.scopeDesc.${scope}`)}
</span>
</button>
)
})}
</div>
{currentWorkDir && (draft.scope === 'local' || draft.scope === 'project') && (
<p className="mt-3 break-all text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.mcp.currentProjectHint', { path: currentWorkDir })}
</p>
)}
</section>
<section className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden">

View File

@ -11,7 +11,7 @@ type McpStore = {
createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise<McpServerRecord>
deleteServer: (server: McpServerRecord, cwd?: string) => Promise<void>
toggleServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
toggleServer: (server: McpServerRecord, cwd?: string, sessionId?: string) => Promise<McpServerRecord>
reconnectServer: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise<McpServerRecord>
selectServer: (server: McpServerRecord | null) => void
@ -132,8 +132,8 @@ export const useMcpStore = create<McpStore>((set) => ({
}))
},
toggleServer: async (server, cwd) => {
const response = await mcpApi.toggle(server.name, cwd)
toggleServer: async (server, cwd, sessionId) => {
const response = await mcpApi.toggle(server.name, cwd, sessionId)
const updated = attachProjectPath(response.server, cwd ?? server.projectPath)
set((state) => ({
servers: replaceServer(state.servers, server, updated, cwd ?? server.projectPath),

View File

@ -37,7 +37,9 @@ export type McpServerRecord = {
projectPath?: string
}
export type McpWritableScope = 'local' | 'project' | 'user'
export type McpUpsertPayload = {
scope: string
scope: McpWritableScope
config: McpEditableConfig
}

View File

@ -2,10 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:te
import * as fs from 'fs/promises'
import * as os from 'os'
import * as path from 'path'
import { getOriginalCwd, setCwdState, setOriginalCwd } from '../../bootstrap/state.js'
import * as mcpClient from '../../services/mcp/client.js'
import * as mcpConfig from '../../services/mcp/config.js'
import { _setGlobalConfigCacheForTesting, getProjectPathForConfig } from '../../utils/config.js'
import { getGlobalClaudeFile } from '../../utils/env.js'
import * as mcpHostPreflight from '../services/mcpHostPreflight.js'
import { handleMcpApi } from '../api/mcp.js'
import { conversationService } from '../services/conversationService.js'
let tmpDir: string
let projectRoot: string
@ -15,6 +19,14 @@ let getClaudeCodeMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
let getAllMcpConfigsSpy: ReturnType<typeof spyOn> | undefined
let reconnectSpy: ReturnType<typeof spyOn> | undefined
let hostPreflightSpy: ReturnType<typeof spyOn> | undefined
let originalRequestControl: typeof conversationService.requestControl
let originalHasSession: typeof conversationService.hasSession
function clearConfigPathCaches() {
getGlobalClaudeFile.cache.clear?.()
getProjectPathForConfig.cache.clear?.()
_setGlobalConfigCacheForTesting(null)
}
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-mcp-test-'))
@ -23,6 +35,7 @@ async function setup() {
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
process.env.CLAUDE_CONFIG_DIR = tmpDir
clearConfigPathCaches()
}
async function teardown() {
@ -32,6 +45,7 @@ async function teardown() {
delete process.env.CLAUDE_CONFIG_DIR
}
clearConfigPathCaches()
await fs.rm(tmpDir, { recursive: true, force: true })
}
@ -62,6 +76,8 @@ describe('MCP API', () => {
ok: true,
resolvedCommand: '/usr/bin/mock-command',
})
originalRequestControl = conversationService.requestControl.bind(conversationService)
originalHasSession = conversationService.hasSession.bind(conversationService)
connectSpy = spyOn(mcpClient, 'connectToServer').mockImplementation(async (name, config) => ({
name,
@ -84,9 +100,69 @@ describe('MCP API', () => {
reconnectSpy = undefined
hostPreflightSpy?.mockRestore()
hostPreflightSpy = undefined
conversationService.requestControl = originalRequestControl
conversationService.hasSession = originalHasSession
await teardown()
})
it('writes local MCP config and disabled state to the requested cwd project', async () => {
const previousNodeEnv = process.env.NODE_ENV
const previousOriginalCwd = getOriginalCwd()
const projectA = path.join(tmpDir, 'project-a')
const projectB = path.join(tmpDir, 'project-b')
await fs.mkdir(projectA, { recursive: true })
await fs.mkdir(projectB, { recursive: true })
process.env.NODE_ENV = 'development'
clearConfigPathCaches()
setOriginalCwd(projectA)
setCwdState(projectA)
try {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectB,
name: 'scoped-server',
scope: 'local',
config: {
type: 'stdio',
command: 'node',
args: ['server.js'],
env: {},
},
})
const createRes = await handleMcpApi(create.req, create.url, create.segments)
expect(createRes.status).toBe(201)
const disable = makeRequest('POST', '/api/mcp/scoped-server/toggle', {
cwd: projectB,
})
const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments)
expect(disableRes.status).toBe(200)
const rawConfig = JSON.parse(
await fs.readFile(path.join(tmpDir, '.claude.json'), 'utf8'),
)
expect(rawConfig.projects?.[projectA]?.mcpServers?.['scoped-server']).toBeUndefined()
expect(rawConfig.projects?.[projectA]?.disabledMcpServers ?? []).not.toContain('scoped-server')
expect(rawConfig.projects?.[projectB]?.mcpServers?.['scoped-server']).toMatchObject({
type: 'stdio',
command: 'node',
})
expect(rawConfig.projects?.[projectB]?.disabledMcpServers).toContain('scoped-server')
} finally {
if (previousNodeEnv === undefined) {
delete process.env.NODE_ENV
} else {
process.env.NODE_ENV = previousNodeEnv
}
setOriginalCwd(previousOriginalCwd)
setCwdState(previousOriginalCwd)
clearConfigPathCaches()
}
})
it('creates and lists local MCP servers for the requested cwd', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
@ -288,6 +364,38 @@ describe('MCP API', () => {
expect(listBody.servers.some((server: { name: string }) => server.name === 'context7')).toBe(false)
})
it('syncs MCP toggles into the active CLI session control channel', async () => {
const create = makeRequest('POST', '/api/mcp', {
cwd: projectRoot,
name: 'session-sync',
scope: 'local',
config: {
type: 'stdio',
command: 'npx',
args: ['session-sync-mcp'],
env: {},
},
})
await handleMcpApi(create.req, create.url, create.segments)
const requestControl = mock(async () => ({}))
conversationService.hasSession = ((sessionId: string) => sessionId === 'session-1') as typeof conversationService.hasSession
conversationService.requestControl = requestControl as typeof conversationService.requestControl
const disable = makeRequest('POST', '/api/mcp/session-sync/toggle', {
cwd: projectRoot,
sessionId: 'session-1',
})
const disableRes = await handleMcpApi(disable.req, disable.url, disable.segments)
expect(disableRes.status).toBe(200)
expect(requestControl).toHaveBeenCalledWith(
'session-1',
{ subtype: 'mcp_toggle', serverName: 'session-sync', enabled: false },
120_000,
)
})
it('reconnects plugin-scoped MCP servers exposed via the merged server list', async () => {
const pluginServerName = 'plugin:telegram:telegram'
const pluginServerConfig = {

View File

@ -29,6 +29,7 @@ import { describeMcpConfigFilePath, ensureConfigScope } from '../../services/mcp
import { enableConfigs } from '../../utils/config.js'
import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { conversationService } from '../services/conversationService.js'
type McpEditableConfigDto =
| {
@ -71,9 +72,16 @@ type McpServerDto = {
type McpMutationBody = {
cwd?: string
scope?: string
sessionId?: string
config?: unknown
}
type McpSessionSyncDto = {
applied: boolean
reason?: 'not_running' | 'failed'
error?: string
}
const EDITABLE_SCOPES = new Set<ConfigScope>(['local', 'project', 'user'])
function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
@ -85,6 +93,36 @@ function parseJsonBody(req: Request): Promise<Record<string, unknown>> {
})
}
function optionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined
}
async function syncMcpToggleToSession(
sessionId: string | undefined,
serverName: string,
enabled: boolean,
): Promise<McpSessionSyncDto | undefined> {
if (!sessionId) return undefined
if (!conversationService.hasSession(sessionId)) {
return { applied: false, reason: 'not_running' }
}
try {
await conversationService.requestControl(
sessionId,
{ subtype: 'mcp_toggle', serverName, enabled },
120_000,
)
return { applied: true }
} catch (error) {
return {
applied: false,
reason: 'failed',
error: error instanceof Error ? error.message : String(error),
}
}
}
function resolveRequestCwd(url: URL, body?: Record<string, unknown>): string {
const cwd = url.searchParams.get('cwd') || (typeof body?.cwd === 'string' ? body.cwd : undefined)
return cwd || getCwd()
@ -500,7 +538,7 @@ async function deleteServer(name: string, url: URL): Promise<Response> {
return Response.json({ ok: true })
}
async function toggleServer(name: string): Promise<Response> {
async function toggleServer(name: string, sessionId?: string): Promise<Response> {
const existing = await resolveServerForRuntimeAction(name)
if (!existing) {
throw ApiError.notFound(`MCP server not found: ${name}`)
@ -508,11 +546,12 @@ async function toggleServer(name: string): Promise<Response> {
const enabled = isMcpServerDisabled(name)
setMcpServerEnabled(name, enabled)
const sessionSync = await syncMcpToggleToSession(sessionId, name, enabled)
if (!enabled) {
await clearServerCache(name, existing).catch(() => {})
const updated = serializeServerSnapshot(name, existing)
return Response.json({ server: updated })
return Response.json({ server: updated, ...(sessionSync ? { sessionSync } : {}) })
}
const hostPreflightStatus = await getHostPreflightStatus(existing, true)
@ -535,6 +574,7 @@ async function toggleServer(name: string): Promise<Response> {
...updated,
...(statusDetail ? { statusDetail } : {}),
},
...(sessionSync ? { sessionSync } : {}),
})
}
@ -604,7 +644,7 @@ export async function handleMcpApi(
}
if (req.method === 'POST' && serverName && action === 'toggle') {
return toggleServer(serverName)
return toggleServer(serverName, optionalString(body?.sessionId))
}
if (req.method === 'POST' && serverName && action === 'reconnect') {

View File

@ -56,6 +56,16 @@ import {
} from './types.js'
import { getProjectMcpServerStatus } from './utils.js'
function getMcpProjectConfig() {
return getCurrentProjectConfig(getCwd())
}
function saveMcpProjectConfig(
updater: Parameters<typeof saveCurrentProjectConfig>[0],
): void {
saveCurrentProjectConfig(updater, getCwd())
}
/**
* Get the path to the managed MCP configuration file
*/
@ -693,7 +703,7 @@ export async function addMcpConfig(
break
}
case 'local': {
const projectConfig = getCurrentProjectConfig()
const projectConfig = getMcpProjectConfig()
if (projectConfig.mcpServers?.[name]) {
throw new Error(`MCP server ${name} already exists in local config`)
}
@ -743,7 +753,7 @@ export async function addMcpConfig(
}
case 'local': {
saveCurrentProjectConfig(current => ({
saveMcpProjectConfig(current => ({
...current,
mcpServers: {
...current.mcpServers,
@ -812,11 +822,11 @@ export async function removeMcpConfig(
case 'local': {
// Check if server exists before updating
const config = getCurrentProjectConfig()
const config = getMcpProjectConfig()
if (!config.mcpServers?.[name]) {
throw new Error(`No project-local MCP server found with name: ${name}`)
}
saveCurrentProjectConfig(current => {
saveMcpProjectConfig(current => {
const { [name]: _, ...restMcpServers } = current.mcpServers ?? {}
return {
...current,
@ -975,7 +985,7 @@ export function getMcpConfigsByScope(
}
}
case 'local': {
const mcpServers = getCurrentProjectConfig().mcpServers
const mcpServers = getMcpProjectConfig().mcpServers
if (!mcpServers) {
return { servers: {}, errors: [] }
}
@ -1522,7 +1532,7 @@ function isDefaultDisabledBuiltin(_name: string): boolean {
* @returns true if the server is disabled
*/
export function isMcpServerDisabled(name: string): boolean {
const projectConfig = getCurrentProjectConfig()
const projectConfig = getMcpProjectConfig()
if (isDefaultDisabledBuiltin(name)) {
const enabledServers = projectConfig.enabledMcpServers || []
return !enabledServers.includes(name)
@ -1550,7 +1560,7 @@ export function setMcpServerEnabled(name: string, enabled: boolean): void {
const isBuiltinStateChange =
isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled
saveCurrentProjectConfig(current => {
saveMcpProjectConfig(current => {
if (isDefaultDisabledBuiltin(name)) {
const prev = current.enabledMcpServers || []
const next = toggleMembership(prev, name, enabled)

View File

@ -1584,10 +1584,8 @@ function getConfig<A>(
}
}
// Memoized function to get the project path for config lookup
export const getProjectPathForConfig = memoize((): string => {
const originalCwd = getOriginalCwd()
const gitRoot = findCanonicalGitRoot(originalCwd)
function computeProjectPathForConfig(cwd: string): string {
const gitRoot = findCanonicalGitRoot(cwd)
if (gitRoot) {
// Normalize for consistent JSON keys (forward slashes on all platforms)
@ -1596,16 +1594,26 @@ export const getProjectPathForConfig = memoize((): string => {
}
// Not in a git repo
return normalizePathForConfigKey(resolve(originalCwd))
})
return normalizePathForConfigKey(resolve(cwd))
}
export function getCurrentProjectConfig(): ProjectConfig {
if (process.env.NODE_ENV === 'test') {
// Memoized function to get the project path for config lookup.
// Callers that operate on an explicit workspace, such as the desktop MCP API,
// must pass cwd so local project config is keyed to the requested project.
export const getProjectPathForConfig = memoize(
(cwd?: string): string => computeProjectPathForConfig(cwd ?? getOriginalCwd()),
(cwd?: string): string => normalizePathForConfigKey(resolve(cwd ?? getOriginalCwd())),
)
export function getCurrentProjectConfig(cwd?: string): ProjectConfig {
if (process.env.NODE_ENV === 'test' && cwd === undefined) {
return TEST_PROJECT_CONFIG_FOR_TESTING
}
const absolutePath = getProjectPathForConfig()
const config = getGlobalConfig()
const absolutePath = getProjectPathForConfig(cwd)
const config = process.env.NODE_ENV === 'test'
? getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig)
: getGlobalConfig()
if (!config.projects) {
return DEFAULT_PROJECT_CONFIG
@ -1624,8 +1632,9 @@ export function getCurrentProjectConfig(): ProjectConfig {
export function saveCurrentProjectConfig(
updater: (currentConfig: ProjectConfig) => ProjectConfig,
cwd?: string,
): void {
if (process.env.NODE_ENV === 'test') {
if (process.env.NODE_ENV === 'test' && cwd === undefined) {
const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING)
// Skip if no changes (same reference returned)
if (config === TEST_PROJECT_CONFIG_FOR_TESTING) {
@ -1634,7 +1643,7 @@ export function saveCurrentProjectConfig(
Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config)
return
}
const absolutePath = getProjectPathForConfig()
const absolutePath = getProjectPathForConfig(cwd)
let written: GlobalConfig | null = null
try {