From 3d2b219eda261638e7ee01e774f8430dd57a9c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 24 May 2026 15:55:38 +0800 Subject: [PATCH] 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. --- desktop/src/__tests__/mcpSettings.test.tsx | 56 ++++++++++- desktop/src/api/mcp.ts | 10 +- desktop/src/i18n/locales/en.ts | 5 +- desktop/src/i18n/locales/zh.ts | 5 +- desktop/src/pages/McpSettings.tsx | 47 +++++++-- desktop/src/stores/mcpStore.ts | 6 +- desktop/src/types/mcp.ts | 4 +- src/server/__tests__/mcp.test.ts | 108 +++++++++++++++++++++ src/server/api/mcp.ts | 46 ++++++++- src/services/mcp/config.ts | 24 +++-- src/utils/config.ts | 33 ++++--- 11 files changed, 303 insertions(+), 41 deletions(-) diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index a1f9bf20..32ee2f6b 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -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() + + 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 () => { diff --git a/desktop/src/api/mcp.ts b/desktop/src/api/mcp.ts index 113aa81b..973d23ef 100644 --- a/desktop/src/api/mcp.ts +++ b/desktop/src/api/mcp.ts @@ -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) => { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 2699ed09..97531cbc 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 5f612f24..38d9db79 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -469,7 +469,7 @@ export const zh: Record = { // 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 = { '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 服务。', diff --git a/desktop/src/pages/McpSettings.tsx b/desktop/src/pages/McpSettings.tsx index c7541ad5..e1de8656 100644 --- a/desktop/src/pages/McpSettings.tsx +++ b/desktop/src/pages/McpSettings.tsx @@ -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 = { 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 { return config.type === 'stdio' } @@ -108,6 +116,7 @@ function isRemoteConfig(config: McpServerRecord['config']): config is Extract { 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() {
{t('settings.mcp.form.scope')}
-

- {t('settings.mcp.globalOnlyHint')} -

+
+ {WRITABLE_SCOPES.map((scope) => { + const active = draft.scope === scope + return ( + + ) + })} +
+ {currentWorkDir && (draft.scope === 'local' || draft.scope === 'project') && ( +

+ {t('settings.mcp.currentProjectHint', { path: currentWorkDir })} +

+ )}
diff --git a/desktop/src/stores/mcpStore.ts b/desktop/src/stores/mcpStore.ts index 905848f0..0a61b022 100644 --- a/desktop/src/stores/mcpStore.ts +++ b/desktop/src/stores/mcpStore.ts @@ -11,7 +11,7 @@ type McpStore = { createServer: (name: string, payload: McpUpsertPayload, cwd?: string) => Promise updateServer: (server: McpServerRecord, payload: McpUpsertPayload, cwd?: string) => Promise deleteServer: (server: McpServerRecord, cwd?: string) => Promise - toggleServer: (server: McpServerRecord, cwd?: string) => Promise + toggleServer: (server: McpServerRecord, cwd?: string, sessionId?: string) => Promise reconnectServer: (server: McpServerRecord, cwd?: string) => Promise refreshServerStatus: (server: McpServerRecord, cwd?: string) => Promise selectServer: (server: McpServerRecord | null) => void @@ -132,8 +132,8 @@ export const useMcpStore = create((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), diff --git a/desktop/src/types/mcp.ts b/desktop/src/types/mcp.ts index 247c05db..f7cc7692 100644 --- a/desktop/src/types/mcp.ts +++ b/desktop/src/types/mcp.ts @@ -37,7 +37,9 @@ export type McpServerRecord = { projectPath?: string } +export type McpWritableScope = 'local' | 'project' | 'user' + export type McpUpsertPayload = { - scope: string + scope: McpWritableScope config: McpEditableConfig } diff --git a/src/server/__tests__/mcp.test.ts b/src/server/__tests__/mcp.test.ts index 68b07185..77ce42c7 100644 --- a/src/server/__tests__/mcp.test.ts +++ b/src/server/__tests__/mcp.test.ts @@ -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 | undefined let getAllMcpConfigsSpy: ReturnType | undefined let reconnectSpy: ReturnType | undefined let hostPreflightSpy: ReturnType | 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 = { diff --git a/src/server/api/mcp.ts b/src/server/api/mcp.ts index 802822a6..f2555464 100644 --- a/src/server/api/mcp.ts +++ b/src/server/api/mcp.ts @@ -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(['local', 'project', 'user']) function parseJsonBody(req: Request): Promise> { @@ -85,6 +93,36 @@ function parseJsonBody(req: Request): Promise> { }) } +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 { + 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 { 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 { return Response.json({ ok: true }) } -async function toggleServer(name: string): Promise { +async function toggleServer(name: string, sessionId?: string): Promise { 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 { 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 { ...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') { diff --git a/src/services/mcp/config.ts b/src/services/mcp/config.ts index 4b4c1edd..19ec7af1 100644 --- a/src/services/mcp/config.ts +++ b/src/services/mcp/config.ts @@ -56,6 +56,16 @@ import { } from './types.js' import { getProjectMcpServerStatus } from './utils.js' +function getMcpProjectConfig() { + return getCurrentProjectConfig(getCwd()) +} + +function saveMcpProjectConfig( + updater: Parameters[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) diff --git a/src/utils/config.ts b/src/utils/config.ts index 36b624c7..8c6741ed 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -1584,10 +1584,8 @@ function getConfig( } } -// 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 {