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:
126
src/mcplocal/tests/smoke/cache.test.ts
Normal file
126
src/mcplocal/tests/smoke/cache.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/mcplocal/tests/smoke/hot-reload.test.ts
Normal file
64
src/mcplocal/tests/smoke/hot-reload.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
143
src/mcplocal/tests/smoke/pause.test.ts
Normal file
143
src/mcplocal/tests/smoke/pause.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user