From c4890bdafc70098f8cfc30576659a9d455df9ef9 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: Mon, 25 May 2026 20:28:00 +0800 Subject: [PATCH] fix: keep project MCP entries visible in settings The settings page could create project-scoped MCP servers for an explicit target path, but subsequent reloads only fetched the active session cwd. That made newly-added local or project MCP entries disappear when the target project differed from the active session or was only represented in the user config. Load MCP settings from the active project, recent projects, and project keys that already contain user-private MCP config. Project-scoped rows now render their project path and use project-aware identity keys so same-name servers in different projects stay distinct. Constraint: MCP local scope is keyed by project path in the user config, while project scope still resolves through the request cwd Rejected: Filesystem scan for every possible .mcp.json | unbounded and too expensive for settings load Confidence: high Scope-risk: moderate Directive: Do not collapse settings MCP fetches back to a single cwd; project/local scopes are cwd-sensitive 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 Tested: git diff --check Not-tested: Discovery of project-shared .mcp.json files in directories that are neither recent projects nor active workdirs --- desktop/src/__tests__/mcpSettings.test.tsx | 69 +++++++++++++++++++++- desktop/src/api/mcp.ts | 4 ++ desktop/src/pages/McpSettings.tsx | 61 +++++++++++++++---- src/server/__tests__/mcp.test.ts | 38 ++++++++++++ src/server/api/mcp.ts | 16 ++++- 5 files changed, 174 insertions(+), 14 deletions(-) diff --git a/desktop/src/__tests__/mcpSettings.test.tsx b/desktop/src/__tests__/mcpSettings.test.tsx index 5913c738..3d59a170 100644 --- a/desktop/src/__tests__/mcpSettings.test.tsx +++ b/desktop/src/__tests__/mcpSettings.test.tsx @@ -4,6 +4,7 @@ import '@testing-library/jest-dom' import { McpSettings } from '../pages/McpSettings' import { sessionsApi } from '../api/sessions' +import { mcpApi } from '../api/mcp' import { useMcpStore } from '../stores/mcpStore' import { useSessionStore } from '../stores/sessionStore' import { useSettingsStore } from '../stores/settingsStore' @@ -19,6 +20,17 @@ vi.mock('../api/sessions', async (importOriginal) => { } }) +vi.mock('../api/mcp', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + mcpApi: { + ...actual.mcpApi, + projectPaths: vi.fn(), + }, + } +}) + describe('McpSettings', () => { beforeEach(() => { vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ @@ -33,6 +45,9 @@ describe('McpSettings', () => { sessionCount: 1, }], }) + vi.mocked(mcpApi.projectPaths).mockResolvedValue({ + projectPaths: ['/workspace/config-project'], + }) useSettingsStore.setState({ locale: 'en' }) useSessionStore.setState({ sessions: [ @@ -73,13 +88,18 @@ describe('McpSettings', () => { }) }) - it('loads MCP servers for the active project on mount', () => { + it('loads MCP servers for the active and recent projects on mount', async () => { const fetchServers = vi.fn() useMcpStore.setState({ fetchServers }) render() - expect(fetchServers).toHaveBeenCalledWith(undefined, '/workspace/project') + await waitFor(() => { + expect(fetchServers).toHaveBeenCalledWith( + ['/workspace/project', '/workspace/selected-project', '/workspace/config-project'], + '/workspace/project', + ) + }) }) it('renders the empty state and add button', () => { @@ -134,6 +154,51 @@ describe('McpSettings', () => { expect(screen.getByText('global-user')).toBeInTheDocument() }) + it('keeps same-name project MCP servers distinct by project path', () => { + useMcpStore.setState({ + servers: [ + { + name: 'context7', + scope: 'local', + transport: 'stdio', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/workspace/project-a/.claude.json', + summary: 'npx @upstash/context7-mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + projectPath: '/workspace/project-a', + config: { type: 'stdio', command: 'npx', args: ['@upstash/context7-mcp'], env: {} }, + }, + { + name: 'context7', + scope: 'local', + transport: 'stdio', + enabled: true, + status: 'connected', + statusLabel: 'Connected', + configLocation: '/workspace/project-b/.claude.json', + summary: 'npx @upstash/context7-mcp', + canEdit: true, + canRemove: true, + canReconnect: true, + canToggle: true, + projectPath: '/workspace/project-b', + config: { type: 'stdio', command: 'npx', args: ['@upstash/context7-mcp'], env: {} }, + }, + ], + }) + + render() + + expect(screen.getAllByText('context7')).toHaveLength(2) + expect(screen.getByText('/workspace/project-a')).toBeInTheDocument() + expect(screen.getByText('/workspace/project-b')).toBeInTheDocument() + }) + it('starts background status refresh after the fast list render', async () => { const server = { name: 'deepwiki', diff --git a/desktop/src/api/mcp.ts b/desktop/src/api/mcp.ts index f8057ead..904fbe3a 100644 --- a/desktop/src/api/mcp.ts +++ b/desktop/src/api/mcp.ts @@ -7,6 +7,10 @@ export const mcpApi = { return api.get<{ servers: McpServerRecord[] }>(`/api/mcp${query}`) }, + projectPaths: () => { + return api.get<{ projectPaths: string[] }>('/api/mcp/project-paths') + }, + status: (name: string, cwd?: string) => { const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : '' return api.get<{ server: McpServerRecord }>(`/api/mcp/${encodeURIComponent(name)}/status${query}`) diff --git a/desktop/src/pages/McpSettings.tsx b/desktop/src/pages/McpSettings.tsx index 9e432531..345f0c34 100644 --- a/desktop/src/pages/McpSettings.tsx +++ b/desktop/src/pages/McpSettings.tsx @@ -7,6 +7,8 @@ import { useTranslation } from '../i18n' import { useUIStore } from '../stores/uiStore' import { useMcpStore } from '../stores/mcpStore' import { useSessionStore } from '../stores/sessionStore' +import { sessionsApi } from '../api/sessions' +import { mcpApi } from '../api/mcp' import type { McpServerRecord, McpUpsertPayload, McpWritableScope } from '../types/mcp' type EditorMode = @@ -112,6 +114,10 @@ function scopeRequiresProject(scope: McpWritableScope) { return scope === 'local' || scope === 'project' } +function serverHasProjectContext(server: Pick) { + return (server.scope === 'local' || server.scope === 'project') && !!server.projectPath +} + function isStdioConfig(config: McpServerRecord['config']): config is Extract { return config.type === 'stdio' } @@ -393,6 +399,14 @@ function ServerRow({ {scopeLabel(server, t)} + {serverHasProjectContext(server) && ( + + {server.projectPath} + + )} {server.summary} {server.statusDetail && ( @@ -424,8 +438,9 @@ export function McpSettings() { const [draft, setDraft] = useState(createEmptyDraft) const [isSaving, setIsSaving] = useState(false) const [isDeleting, setIsDeleting] = useState(false) - const [busyServerName, setBusyServerName] = useState(null) + const [busyServerKey, setBusyServerKey] = useState(null) const [pendingDeleteServer, setPendingDeleteServer] = useState(null) + const projectPathsForFetchRef = useRef(undefined) const refreshInFlightRef = useRef(new Set()) const activeSession = sessions.find((session) => session.id === activeSessionId) @@ -433,7 +448,31 @@ export function McpSettings() { const resolveOperationCwd = (server?: McpServerRecord) => server?.projectPath ?? currentWorkDir useEffect(() => { - void fetchServers(undefined, currentWorkDir) + let cancelled = false + + Promise.all([ + sessionsApi.getRecentProjects() + .then(({ projects }) => projects.map((project) => project.realPath)) + .catch(() => []), + mcpApi.projectPaths() + .then(({ projectPaths }) => projectPaths) + .catch(() => []), + ]) + .then(([recentProjectPaths, privateMcpProjectPaths]) => { + if (cancelled) return + const paths = [ + currentWorkDir, + ...recentProjectPaths, + ...privateMcpProjectPaths, + ].filter((path): path is string => !!path) + const projectPathsForFetch = Array.from(new Set(paths)) + projectPathsForFetchRef.current = projectPathsForFetch.length ? projectPathsForFetch : undefined + void fetchServers(projectPathsForFetchRef.current, currentWorkDir) + }) + + return () => { + cancelled = true + } }, [fetchServers, currentWorkDir]) const groupedServers = useMemo(() => { @@ -522,7 +561,7 @@ export function McpSettings() { }, [servers, refreshServerStatus, currentWorkDir]) const handleToggle = async (server: McpServerRecord) => { - setBusyServerName(server.name) + setBusyServerKey(getServerIdentityKey(server)) try { const updated = await toggleServer(server, resolveOperationCwd(server), activeSessionId ?? undefined) addToast({ @@ -535,7 +574,7 @@ export function McpSettings() { message: error instanceof Error ? error.message : t('settings.mcp.toast.toggleFailed'), }) } finally { - setBusyServerName(null) + setBusyServerKey(null) } } @@ -547,7 +586,7 @@ export function McpSettings() { statusDetail: undefined, } - setBusyServerName(server.name) + setBusyServerKey(getServerIdentityKey(server)) setView((current) => { if (current.type !== 'details' && current.type !== 'edit') return current if (getServerIdentityKey(current.server) !== getServerIdentityKey(server)) return current @@ -574,7 +613,7 @@ export function McpSettings() { message: error instanceof Error ? error.message : t('settings.mcp.toast.reconnectFailed'), }) } finally { - setBusyServerName(null) + setBusyServerKey(null) } } @@ -719,7 +758,7 @@ export function McpSettings() { {server.canReconnect && ( - @@ -799,7 +838,7 @@ export function McpSettings() {
{editing && targetServer?.canReconnect && ( - @@ -1038,7 +1077,7 @@ export function McpSettings() {

{error}