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}