feat: file cache, pause queue, hot-reload, and cache CLI commands

- Persistent file cache in ~/.mcpctl/cache/proxymodel/ with LRU eviction
- Pause queue for temporarily holding MCP traffic
- Hot-reload watcher for custom stages and proxymodel definitions
- CLI: mcpctl cache list/clear/stats commands
- HTTP endpoints for cache and pause management

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-03-07 23:36:55 +00:00
parent 1665b12c0c
commit a2728f280a
20 changed files with 2082 additions and 10 deletions

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import { isMcplocalRunning, mcpctl } from './mcp-client.js';
const MCPLOCAL_URL = process.env['MCPLOCAL_URL'] ?? 'http://localhost:3200';
let available = false;
function fetchJson<T>(urlPath: string, method = 'GET'): Promise<T | null> {
return new Promise((resolve) => {
const req = http.request(`${MCPLOCAL_URL}${urlPath}`, { method, timeout: 5000 }, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString()) as T);
} catch {
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.on('timeout', () => { req.destroy(); resolve(null); });
req.end();
});
}
beforeAll(async () => {
available = await isMcplocalRunning();
});
interface CacheStats {
rootDir: string;
totalSize: number;
totalEntries: number;
namespaces: Array<{ name: string; entries: number; size: number }>;
}
interface ClearResult {
removed: number;
freedBytes: number;
}
describe('Cache smoke tests', () => {
describe('mcplocal /cache endpoints', () => {
it('GET /cache/stats returns valid stats structure', async () => {
if (!available) return;
const stats = await fetchJson<CacheStats>('/cache/stats');
expect(stats).not.toBeNull();
expect(stats).toHaveProperty('rootDir');
expect(stats).toHaveProperty('totalSize');
expect(stats).toHaveProperty('totalEntries');
expect(stats).toHaveProperty('namespaces');
expect(Array.isArray(stats!.namespaces)).toBe(true);
expect(typeof stats!.totalSize).toBe('number');
expect(typeof stats!.totalEntries).toBe('number');
});
it('namespaces use provider--model--proxymodel format', async () => {
if (!available) return;
const stats = await fetchJson<CacheStats>('/cache/stats');
if (!stats || stats.namespaces.length === 0) return;
// Each namespace should contain -- separators
for (const ns of stats.namespaces) {
expect(ns.name).toBeTruthy();
expect(typeof ns.entries).toBe('number');
expect(typeof ns.size).toBe('number');
}
});
it('DELETE /cache returns clear result', async () => {
if (!available) return;
// This clears the cache, but it's non-destructive for a smoke test
const result = await fetchJson<ClearResult>('/cache', 'DELETE');
expect(result).not.toBeNull();
expect(result).toHaveProperty('removed');
expect(result).toHaveProperty('freedBytes');
expect(typeof result!.removed).toBe('number');
expect(typeof result!.freedBytes).toBe('number');
});
});
describe('mcpctl cache CLI', () => {
it('mcpctl cache stats shows cache statistics', async () => {
if (!available) return;
const output = await mcpctl('cache stats');
// Should either show table or "Cache is empty."
expect(output.length).toBeGreaterThan(0);
const hasTable = output.includes('NAMESPACE');
const isEmpty = output.includes('Cache is empty');
expect(hasTable || isEmpty).toBe(true);
});
it('mcpctl cache clear runs without error', async () => {
if (!available) return;
const output = await mcpctl('cache clear');
// Should report what was cleared, or that cache is empty
expect(output).toMatch(/[Cc]lear|empty/i);
});
});
describe('cache namespace isolation', () => {
it('stats show separate namespaces per llm provider/model/proxymodel combo', async () => {
if (!available) return;
// After any project MCP sessions have run, check that namespaces
// follow the provider--model--proxymodel convention
const stats = await fetchJson<CacheStats>('/cache/stats');
if (!stats || stats.namespaces.length === 0) return;
// Namespaces with -- separators indicate proper isolation
const separated = stats.namespaces.filter((ns) => ns.name.includes('--'));
// If there are namespaces, at least some should have the separator format
// (the 'dynamic' namespace from hot-swap is an exception)
if (stats.namespaces.length > 1) {
expect(separated.length).toBeGreaterThan(0);
}
});
});
});