fix(extensions): scope mcp installed cache by project
This commit is contained in:
parent
0420428281
commit
be8f4f45d2
2 changed files with 105 additions and 6 deletions
|
|
@ -27,15 +27,16 @@ interface TimedCache<T> {
|
|||
}
|
||||
|
||||
export class McpInstallationStateService {
|
||||
private cache: TimedCache<InstalledMcpEntry[]> | null = null;
|
||||
private cache = new Map<string, TimedCache<InstalledMcpEntry[]>>();
|
||||
|
||||
/**
|
||||
* Get all installed MCP servers across user and project scopes.
|
||||
*/
|
||||
async getInstalled(projectPath?: string): Promise<InstalledMcpEntry[]> {
|
||||
// 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 ────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue