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,137 @@
import { Command } from 'commander';
import http from 'node:http';
export interface CacheCommandDeps {
log: (...args: string[]) => void;
mcplocalUrl?: string;
}
interface NamespaceStats {
name: string;
entries: number;
size: number;
oldestMs: number;
newestMs: number;
}
interface CacheStats {
rootDir: string;
totalSize: number;
totalEntries: number;
namespaces: NamespaceStats[];
}
interface ClearResult {
removed: number;
freedBytes: number;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
}
function formatAge(ms: number): string {
if (ms === 0) return '-';
const age = Date.now() - ms;
const days = Math.floor(age / (24 * 60 * 60 * 1000));
if (days > 0) return `${days}d ago`;
const hours = Math.floor(age / (60 * 60 * 1000));
if (hours > 0) return `${hours}h ago`;
const mins = Math.floor(age / (60 * 1000));
return `${mins}m ago`;
}
function fetchJson<T>(url: string, method = 'GET'): Promise<T> {
return new Promise((resolve, reject) => {
const req = http.request(url, { method, timeout: 5000 }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
resolve(JSON.parse(data) as T);
} catch {
reject(new Error(`Invalid response from mcplocal: ${data.slice(0, 200)}`));
}
});
});
req.on('error', () => reject(new Error('Cannot connect to mcplocal. Is it running?')));
req.on('timeout', () => { req.destroy(); reject(new Error('mcplocal request timed out')); });
req.end();
});
}
export function createCacheCommand(deps: CacheCommandDeps): Command {
const cache = new Command('cache')
.description('Manage ProxyModel pipeline cache');
const mcplocalUrl = deps.mcplocalUrl ?? 'http://localhost:3200';
cache
.command('stats')
.description('Show cache statistics')
.action(async () => {
const stats = await fetchJson<CacheStats>(`${mcplocalUrl}/cache/stats`);
if (stats.totalEntries === 0) {
deps.log('Cache is empty.');
return;
}
deps.log(`Cache: ${formatBytes(stats.totalSize)} total, ${stats.totalEntries} entries`);
deps.log(`Path: ${stats.rootDir}`);
deps.log('');
// Table header
const pad = (s: string, w: number) => s.padEnd(w);
deps.log(
`${pad('NAMESPACE', 40)} ${pad('ENTRIES', 8)} ${pad('SIZE', 10)} ${pad('OLDEST', 12)} NEWEST`,
);
deps.log(
`${pad('-'.repeat(40), 40)} ${pad('-'.repeat(8), 8)} ${pad('-'.repeat(10), 10)} ${pad('-'.repeat(12), 12)} ${'-'.repeat(12)}`,
);
for (const ns of stats.namespaces) {
deps.log(
`${pad(ns.name, 40)} ${pad(String(ns.entries), 8)} ${pad(formatBytes(ns.size), 10)} ${pad(formatAge(ns.oldestMs), 12)} ${formatAge(ns.newestMs)}`,
);
}
});
cache
.command('clear')
.description('Clear cache entries')
.argument('[namespace]', 'Clear only this namespace')
.option('--older-than <days>', 'Clear entries older than N days')
.option('-y, --yes', 'Skip confirmation')
.action(async (namespace: string | undefined, opts: { olderThan?: string; yes?: boolean }) => {
// Show what will be cleared first
const stats = await fetchJson<CacheStats>(`${mcplocalUrl}/cache/stats`);
if (stats.totalEntries === 0) {
deps.log('Cache is already empty.');
return;
}
const target = namespace
? stats.namespaces.find((ns) => ns.name === namespace)
: null;
if (namespace && !target) {
deps.log(`Namespace '${namespace}' not found.`);
deps.log(`Available: ${stats.namespaces.map((ns) => ns.name).join(', ')}`);
return;
}
const olderThan = opts.olderThan ? `?olderThan=${opts.olderThan}` : '';
const url = namespace
? `${mcplocalUrl}/cache/${encodeURIComponent(namespace)}${olderThan}`
: `${mcplocalUrl}/cache${olderThan}`;
const result = await fetchJson<ClearResult>(url, 'DELETE');
deps.log(`Cleared ${result.removed} entries, freed ${formatBytes(result.freedBytes)}`);
});
return cache;
}

View File

@@ -16,6 +16,7 @@ import { createAttachServerCommand, createDetachServerCommand, createApproveComm
import { createMcpCommand } from './commands/mcp.js';
import { createPatchCommand } from './commands/patch.js';
import { createConsoleCommand } from './commands/console/index.js';
import { createCacheCommand } from './commands/cache.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
@@ -211,6 +212,11 @@ export function createProgram(): Command {
getProject: () => program.opts().project as string | undefined,
}));
program.addCommand(createCacheCommand({
log: (...args) => console.log(...args),
mcplocalUrl: config.mcplocalUrl,
}));
return program;
}