From be8f4f45d219ef59c83eb607033f7581b560cbd4 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 16 Apr 2026 22:41:10 +0300 Subject: [PATCH] fix(extensions): scope mcp installed cache by project --- .../state/McpInstallationStateService.ts | 13 +-- .../McpInstallationStateService.test.ts | 98 +++++++++++++++++++ 2 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 test/main/services/extensions/McpInstallationStateService.test.ts diff --git a/src/main/services/extensions/state/McpInstallationStateService.ts b/src/main/services/extensions/state/McpInstallationStateService.ts index f947f190..6e5950ec 100644 --- a/src/main/services/extensions/state/McpInstallationStateService.ts +++ b/src/main/services/extensions/state/McpInstallationStateService.ts @@ -27,15 +27,16 @@ interface TimedCache { } export class McpInstallationStateService { - private cache: TimedCache | null = null; + private cache = new Map>(); /** * Get all installed MCP servers across user and project scopes. */ async getInstalled(projectPath?: string): Promise { - // Cache is project-path-dependent, so invalidate on path change - if (this.cache && Date.now() - this.cache.fetchedAt < CACHE_TTL_MS) { - return this.cache.data; + const cacheKey = projectPath ?? '__user__'; + const cached = this.cache.get(cacheKey); + if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) { + return cached.data; } const entries: InstalledMcpEntry[] = []; @@ -50,7 +51,7 @@ export class McpInstallationStateService { entries.push(...projectEntries); } - this.cache = { data: entries, fetchedAt: Date.now() }; + this.cache.set(cacheKey, { data: entries, fetchedAt: Date.now() }); return entries; } @@ -58,7 +59,7 @@ export class McpInstallationStateService { * Invalidate cache. Call after install/uninstall operations. */ invalidateCache(): void { - this.cache = null; + this.cache.clear(); } // ── Private ──────────────────────────────────────────────────────────── diff --git a/test/main/services/extensions/McpInstallationStateService.test.ts b/test/main/services/extensions/McpInstallationStateService.test.ts new file mode 100644 index 00000000..9e65a2be --- /dev/null +++ b/test/main/services/extensions/McpInstallationStateService.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; + +import { McpInstallationStateService } from '@main/services/extensions/state/McpInstallationStateService'; + +vi.mock('@main/utils/pathDecoder', () => ({ + getHomeDir: () => '/tmp/mock-home', +})); + +vi.mock('node:fs/promises'); + +describe('McpInstallationStateService', () => { + let service: McpInstallationStateService; + const mockedFs = vi.mocked(fs); + + beforeEach(() => { + service = new McpInstallationStateService(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('getInstalled', () => { + it('caches results within TTL for the same project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + await service.getInstalled('/tmp/project-a'); + await service.getInstalled('/tmp/project-a'); + + expect(mockedFs.readFile).toHaveBeenCalledTimes(2); + }); + + it('caches results independently per project path', async () => { + mockedFs.readFile.mockImplementation(async (filePath) => { + const normalizedPath = String(filePath); + if (normalizedPath === '/tmp/mock-home/.claude.json') { + return JSON.stringify({ + mcpServers: { + context7: { command: 'npx -y @upstash/context7-mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-a/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-a-server': { url: 'https://repo-a.example.com/mcp' }, + }, + }); + } + + if (normalizedPath === '/tmp/project-b/.mcp.json') { + return JSON.stringify({ + mcpServers: { + 'repo-b-server': { command: 'uvx repo-b-mcp' }, + }, + }); + } + + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); + + const projectAEntries = await service.getInstalled('/tmp/project-a'); + const projectBEntries = await service.getInstalled('/tmp/project-b'); + + expect(projectAEntries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'repo-a-server', scope: 'project', transport: 'http' }, + ]); + expect(projectBEntries).toEqual([ + { name: 'context7', scope: 'user', transport: 'stdio' }, + { name: 'repo-b-server', scope: 'project', transport: 'stdio' }, + ]); + expect(mockedFs.readFile).toHaveBeenCalledTimes(4); + }); + }); +});