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);
}
});
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, beforeAll } from 'vitest';
import http from 'node:http';
import { isMcplocalRunning } from './mcp-client.js';
const MCPLOCAL_URL = process.env['MCPLOCAL_URL'] ?? 'http://localhost:3200';
let available = false;
function fetchJson<T>(urlPath: string, method = 'GET', body?: unknown): Promise<T | null> {
return new Promise((resolve) => {
const payload = body !== undefined ? JSON.stringify(body) : undefined;
const req = http.request(`${MCPLOCAL_URL}${urlPath}`, {
method,
timeout: 5000,
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
}, (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); });
if (payload) req.write(payload);
req.end();
});
}
beforeAll(async () => {
available = await isMcplocalRunning();
});
describe('Hot-reload smoke tests', () => {
describe('GET /proxymodels/stages', () => {
it('returns list of stages with source', async () => {
if (!available) return;
const stages = await fetchJson<Array<{ name: string; source: string }>>('/proxymodels/stages');
expect(stages).not.toBeNull();
expect(Array.isArray(stages)).toBe(true);
expect(stages!.length).toBeGreaterThan(0);
// Should have built-in stages
const passthrough = stages!.find((s) => s.name === 'passthrough');
expect(passthrough).toBeDefined();
expect(passthrough!.source).toBe('built-in');
});
});
describe('POST /proxymodels/reload', () => {
it('reloads stages and returns count', async () => {
if (!available) return;
const result = await fetchJson<{ loaded: number }>('/proxymodels/reload', 'POST');
expect(result).not.toBeNull();
expect(typeof result!.loaded).toBe('number');
});
});
});

View File

@@ -0,0 +1,143 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import { isMcplocalRunning } from './mcp-client.js';
const MCPLOCAL_URL = process.env['MCPLOCAL_URL'] ?? 'http://localhost:3200';
let available = false;
function fetchJson<T>(urlPath: string, method = 'GET', body?: unknown): Promise<T | null> {
return new Promise((resolve) => {
const payload = body !== undefined ? JSON.stringify(body) : undefined;
const req = http.request(`${MCPLOCAL_URL}${urlPath}`, {
method,
timeout: 5000,
headers: payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {},
}, (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); });
if (payload) req.write(payload);
req.end();
});
}
beforeAll(async () => {
available = await isMcplocalRunning();
});
afterAll(async () => {
// Always ensure pause is off after tests
if (available) {
await fetchJson('/pause', 'PUT', { paused: false });
}
});
describe('Pause Queue smoke tests', () => {
describe('GET /pause', () => {
it('returns pause state', async () => {
if (!available) return;
const state = await fetchJson<{ paused: boolean; queueSize: number }>('/pause');
expect(state).not.toBeNull();
expect(typeof state!.paused).toBe('boolean');
expect(typeof state!.queueSize).toBe('number');
});
});
describe('PUT /pause', () => {
it('can enable and disable pause mode', async () => {
if (!available) return;
// Enable
const on = await fetchJson<{ paused: boolean; queueSize: number }>('/pause', 'PUT', { paused: true });
expect(on).not.toBeNull();
expect(on!.paused).toBe(true);
// Verify
const state = await fetchJson<{ paused: boolean }>('/pause');
expect(state!.paused).toBe(true);
// Disable
const off = await fetchJson<{ paused: boolean; queueSize: number }>('/pause', 'PUT', { paused: false });
expect(off).not.toBeNull();
expect(off!.paused).toBe(false);
});
it('rejects non-boolean paused value', async () => {
if (!available) return;
const result = await fetchJson<{ error: string }>('/pause', 'PUT', { paused: 'yes' });
expect(result).not.toBeNull();
expect(result!.error).toBeTruthy();
});
});
describe('GET /pause/queue', () => {
it('returns empty queue when not paused', async () => {
if (!available) return;
const result = await fetchJson<{ paused: boolean; items: unknown[] }>('/pause/queue');
expect(result).not.toBeNull();
expect(Array.isArray(result!.items)).toBe(true);
});
});
describe('POST /pause/release-all', () => {
it('returns released count', async () => {
if (!available) return;
const result = await fetchJson<{ released: number; queueSize: number }>('/pause/release-all', 'POST');
expect(result).not.toBeNull();
expect(typeof result!.released).toBe('number');
expect(result!.queueSize).toBe(0);
});
});
describe('POST /pause/queue/:id/release', () => {
it('returns 404 for non-existent item', async () => {
if (!available) return;
const result = await fetchJson<{ error: string }>('/pause/queue/nonexistent/release', 'POST');
expect(result).not.toBeNull();
expect(result!.error).toMatch(/not found/i);
});
});
describe('POST /pause/queue/:id/edit', () => {
it('returns 404 for non-existent item', async () => {
if (!available) return;
const result = await fetchJson<{ error: string }>('/pause/queue/nonexistent/edit', 'POST', { content: 'test' });
expect(result).not.toBeNull();
expect(result!.error).toMatch(/not found/i);
});
it('rejects missing content', async () => {
if (!available) return;
const result = await fetchJson<{ error: string }>('/pause/queue/nonexistent/edit', 'POST', {});
expect(result).not.toBeNull();
expect(result!.error).toBeTruthy();
});
});
describe('POST /pause/queue/:id/drop', () => {
it('returns 404 for non-existent item', async () => {
if (!available) return;
const result = await fetchJson<{ error: string }>('/pause/queue/nonexistent/drop', 'POST');
expect(result).not.toBeNull();
expect(result!.error).toMatch(/not found/i);
});
});
});