From 3b9863a14cf21e5aeb598dcaad793870bf9ccba1 Mon Sep 17 00:00:00 2001 From: Shunsuke Date: Thu, 3 Sep 2026 10:40:44 +0800 Subject: [PATCH] fix(desktop): isolate MCP catalog refresh by project --- .../src/features/mcp/useMcpCatalog.test.tsx | 134 ++++++++++++++++++ desktop/src/features/mcp/useMcpCatalog.ts | 11 +- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 desktop/src/features/mcp/useMcpCatalog.test.tsx diff --git a/desktop/src/features/mcp/useMcpCatalog.test.tsx b/desktop/src/features/mcp/useMcpCatalog.test.tsx new file mode 100644 index 00000000..70631759 --- /dev/null +++ b/desktop/src/features/mcp/useMcpCatalog.test.tsx @@ -0,0 +1,134 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { + McpInventory, + McpPresetInventory, + McpProbeResult, + Project, +} from "../../generated/app-server"; +import type { DesktopRuntime, RpcMethod } from "../../rpc/contracts"; +import { useMcpCatalog } from "./useMcpCatalog"; + +function deferred() { + let resolve!: (value: Value) => void; + const promise = new Promise((accept) => { + resolve = accept; + }); + return { promise, resolve }; +} + +function project(id: string): Project { + return { + id, + canonicalPath: `/workspace/${id}`, + displayName: id, + trustState: "trusted", + settings: {}, + createdAt: "2026-09-03T00:00:00Z", + updatedAt: "2026-09-03T00:00:00Z", + lastOpenedAt: "2026-09-03T00:00:00Z", + }; +} + +function inventory(projectId: string): McpInventory { + return { + servers: [], + userConfigPath: "/home/user/.deepcode/deepcode_config.json", + projectConfigPath: `/workspace/${projectId}/deepcode_config.json`, + }; +} + +function presets(projectId: string): McpPresetInventory { + return { + source: projectId, + sourceRevision: "test-revision", + presets: [], + }; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("useMcpCatalog project ownership", () => { + it("ignores a late refresh from a mutation started in the previous project", async () => { + const probeResult = deferred(); + const projectBInventory = deferred(); + const projectBPresets = deferred(); + let projectAListCalls = 0; + + const request = vi.fn((method: RpcMethod, params: unknown) => { + const projectId = (params as { projectId?: string }).projectId; + if (method === "mcp/probe") return probeResult.promise; + if (projectId === "B" && method === "mcp/list") { + return projectBInventory.promise; + } + if (projectId === "B" && method === "mcp/presets") { + return projectBPresets.promise; + } + if (projectId === "A" && method === "mcp/list") { + projectAListCalls += 1; + } + if (method === "mcp/list") return Promise.resolve(inventory(projectId ?? "user")); + if (method === "mcp/presets") return Promise.resolve(presets(projectId ?? "user")); + throw new Error(`Unexpected request: ${method}`); + }); + const runtime = { + request, + onNotification: async () => () => undefined, + } as unknown as DesktopRuntime; + + const { result, rerender } = renderHook( + ({ selectedProject }) => useMcpCatalog(runtime, selectedProject), + { initialProps: { selectedProject: project("A") } }, + ); + + await waitFor(() => { + expect(result.current.inventory?.projectConfigPath).toBe( + "/workspace/A/deepcode_config.json", + ); + }); + + let pendingProbe!: Promise; + act(() => { + pendingProbe = result.current.probe("server"); + }); + rerender({ selectedProject: project("B") }); + + await waitFor(() => { + expect(request).toHaveBeenCalledWith("mcp/list", { projectId: "B" }); + expect(request).toHaveBeenCalledWith("mcp/presets", { projectId: "B" }); + }); + + await act(async () => { + probeResult.resolve({ + serverId: "server", + name: "server", + ok: true, + transport: "stdio", + toolCount: 0, + resourceCount: 0, + promptCount: 0, + elapsedSeconds: 0, + error: null, + }); + await pendingProbe; + }); + + await act(async () => { + projectBInventory.resolve(inventory("B")); + projectBPresets.resolve(presets("B")); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.inventory?.projectConfigPath).toBe( + "/workspace/B/deepcode_config.json", + ); + }); + expect(projectAListCalls).toBe(1); + }); +}); diff --git a/desktop/src/features/mcp/useMcpCatalog.ts b/desktop/src/features/mcp/useMcpCatalog.ts index beb632a2..2ab5d311 100644 --- a/desktop/src/features/mcp/useMcpCatalog.ts +++ b/desktop/src/features/mcp/useMcpCatalog.ts @@ -23,6 +23,7 @@ export function useMcpCatalog(runtime: DesktopRuntime, project: Project | null) const projectId = project?.id; const catalogKey = projectId ?? "__user__"; const generation = useRef(0); + const activeCatalogKey = useRef(catalogKey); const [state, setState] = useState({ key: "", inventory: null, @@ -32,6 +33,10 @@ export function useMcpCatalog(runtime: DesktopRuntime, project: Project | null) }); const load = useCallback(async () => { + // A mutation started for a previous project may finish after the user has + // switched projects. Do not let its captured loader invalidate or replace + // the active project's catalog request. + if (activeCatalogKey.current !== catalogKey) return; const requestGeneration = ++generation.current; setState((current) => ({ ...current, @@ -65,6 +70,7 @@ export function useMcpCatalog(runtime: DesktopRuntime, project: Project | null) }, [catalogKey, projectId, runtime]); useEffect(() => { + activeCatalogKey.current = catalogKey; void load(); let active = true; let unsubscribe: (() => void) | null = null; @@ -83,10 +89,13 @@ export function useMcpCatalog(runtime: DesktopRuntime, project: Project | null) } return () => { active = false; + if (activeCatalogKey.current === catalogKey) { + activeCatalogKey.current = null; + } generation.current += 1; unsubscribe?.(); }; - }, [load, runtime]); + }, [catalogKey, load, runtime]); const mutate = useCallback( async (operation: () => Promise) => {