feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled

- Rename local-proxy to mcplocal with HTTP server, LLM pipeline, mcpd discovery
- Add LLM pre-processing: token estimation, filter cache, metrics, Gemini CLI + DeepSeek providers
- Add mcpd auth (login/logout) and MCP proxy endpoints
- Update CLI: dual URLs (mcplocalUrl/mcpdUrl), auth commands, --direct flag
- Add tiered health monitoring, shell completions, e2e integration tests
- 57 test files, 597 tests passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-22 11:42:06 +00:00
parent a4fe5fdbe2
commit b8c5cf718a
82 changed files with 5832 additions and 123 deletions

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi } from 'vitest';
import { refreshUpstreams } from '../src/discovery.js';
import { McpRouter } from '../src/router.js';
function mockMcpdClient(servers: Array<{ id: string; name: string; transport: string }>) {
return {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => servers),
post: vi.fn(async () => ({ result: {} })),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(),
};
}
describe('refreshUpstreams', () => {
it('registers mcpd servers as upstreams', async () => {
const router = new McpRouter();
const client = mockMcpdClient([
{ id: 'srv-1', name: 'slack', transport: 'stdio' },
{ id: 'srv-2', name: 'github', transport: 'stdio' },
]);
const registered = await refreshUpstreams(router, client as any);
expect(registered).toEqual(['slack', 'github']);
expect(router.getUpstreamNames()).toContain('slack');
expect(router.getUpstreamNames()).toContain('github');
});
it('removes stale upstreams', async () => {
const router = new McpRouter();
// First refresh: 2 servers
const client1 = mockMcpdClient([
{ id: 'srv-1', name: 'slack', transport: 'stdio' },
{ id: 'srv-2', name: 'github', transport: 'stdio' },
]);
await refreshUpstreams(router, client1 as any);
expect(router.getUpstreamNames()).toHaveLength(2);
// Second refresh: only 1 server
const client2 = mockMcpdClient([
{ id: 'srv-1', name: 'slack', transport: 'stdio' },
]);
await refreshUpstreams(router, client2 as any);
expect(router.getUpstreamNames()).toEqual(['slack']);
});
it('does not duplicate existing upstreams', async () => {
const router = new McpRouter();
const client = mockMcpdClient([
{ id: 'srv-1', name: 'slack', transport: 'stdio' },
]);
await refreshUpstreams(router, client as any);
await refreshUpstreams(router, client as any);
expect(router.getUpstreamNames()).toEqual(['slack']);
});
it('handles empty server list', async () => {
const router = new McpRouter();
const client = mockMcpdClient([]);
const registered = await refreshUpstreams(router, client as any);
expect(registered).toEqual([]);
expect(router.getUpstreamNames()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,112 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { FilterCache, DEFAULT_FILTER_CACHE_CONFIG } from '../src/llm/filter-cache.js';
describe('FilterCache', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('returns null for unknown tool names', () => {
const cache = new FilterCache();
expect(cache.shouldFilter('unknown/tool')).toBeNull();
});
it('stores and retrieves filter decisions', () => {
const cache = new FilterCache();
cache.recordDecision('slack/search', true);
expect(cache.shouldFilter('slack/search')).toBe(true);
cache.recordDecision('github/list_repos', false);
expect(cache.shouldFilter('github/list_repos')).toBe(false);
});
it('updates existing entries on re-record', () => {
const cache = new FilterCache();
cache.recordDecision('slack/search', true);
expect(cache.shouldFilter('slack/search')).toBe(true);
cache.recordDecision('slack/search', false);
expect(cache.shouldFilter('slack/search')).toBe(false);
});
it('evicts oldest entry when at capacity', () => {
const cache = new FilterCache({ maxEntries: 3 });
cache.recordDecision('tool-a', true);
cache.recordDecision('tool-b', false);
cache.recordDecision('tool-c', true);
expect(cache.size).toBe(3);
// Adding a 4th should evict 'tool-a' (oldest)
cache.recordDecision('tool-d', false);
expect(cache.size).toBe(3);
expect(cache.shouldFilter('tool-a')).toBeNull();
expect(cache.shouldFilter('tool-b')).toBe(false);
expect(cache.shouldFilter('tool-d')).toBe(false);
});
it('refreshes LRU position on access', () => {
const cache = new FilterCache({ maxEntries: 3 });
cache.recordDecision('tool-a', true);
cache.recordDecision('tool-b', false);
cache.recordDecision('tool-c', true);
// Access tool-a to refresh it
cache.shouldFilter('tool-a');
// Now add tool-d — tool-b should be evicted (oldest unreferenced)
cache.recordDecision('tool-d', false);
expect(cache.shouldFilter('tool-a')).toBe(true);
expect(cache.shouldFilter('tool-b')).toBeNull();
});
it('expires entries after TTL', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const cache = new FilterCache({ ttlMs: 1000 });
cache.recordDecision('slack/search', true);
expect(cache.shouldFilter('slack/search')).toBe(true);
// Advance time past TTL
vi.spyOn(Date, 'now').mockReturnValue(now + 1001);
expect(cache.shouldFilter('slack/search')).toBeNull();
// Entry should be removed
expect(cache.size).toBe(0);
});
it('does not expire entries within TTL', () => {
const now = Date.now();
vi.spyOn(Date, 'now').mockReturnValue(now);
const cache = new FilterCache({ ttlMs: 1000 });
cache.recordDecision('slack/search', true);
// Advance time within TTL
vi.spyOn(Date, 'now').mockReturnValue(now + 999);
expect(cache.shouldFilter('slack/search')).toBe(true);
});
it('clears all entries', () => {
const cache = new FilterCache();
cache.recordDecision('tool-a', true);
cache.recordDecision('tool-b', false);
expect(cache.size).toBe(2);
cache.clear();
expect(cache.size).toBe(0);
expect(cache.shouldFilter('tool-a')).toBeNull();
});
it('uses default config values', () => {
const cache = new FilterCache();
// Should support the default number of entries without issue
for (let i = 0; i < DEFAULT_FILTER_CACHE_CONFIG.maxEntries; i++) {
cache.recordDecision(`tool-${i}`, true);
}
expect(cache.size).toBe(DEFAULT_FILTER_CACHE_CONFIG.maxEntries);
// One more should trigger eviction
cache.recordDecision('extra-tool', true);
expect(cache.size).toBe(DEFAULT_FILTER_CACHE_CONFIG.maxEntries);
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HealthMonitor } from '../src/health.js';
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse } from '../src/types.js';
function mockUpstream(name: string, alive = true): UpstreamConnection {
return {
name,
isAlive: vi.fn(() => alive),
close: vi.fn(async () => {}),
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => ({
jsonrpc: '2.0',
id: req.id,
error: { code: -32601, message: 'Method not found' },
})),
};
}
describe('HealthMonitor', () => {
let monitor: HealthMonitor;
beforeEach(() => {
monitor = new HealthMonitor({ intervalMs: 100, failureThreshold: 2 });
});
afterEach(() => {
monitor.stop();
});
it('tracks upstream and reports initial healthy state', () => {
const upstream = mockUpstream('slack');
monitor.track(upstream);
const status = monitor.getStatus('slack');
expect(status).toBeDefined();
expect(status?.state).toBe('healthy');
expect(status?.consecutiveFailures).toBe(0);
});
it('reports disconnected for dead upstream on track', () => {
const upstream = mockUpstream('slack', false);
monitor.track(upstream);
expect(monitor.getStatus('slack')?.state).toBe('disconnected');
});
it('marks upstream healthy when ping succeeds', async () => {
const upstream = mockUpstream('slack');
monitor.track(upstream);
await monitor.checkAll();
expect(monitor.isHealthy('slack')).toBe(true);
expect(monitor.getStatus('slack')?.consecutiveFailures).toBe(0);
});
it('marks upstream degraded after one failure', async () => {
const upstream = mockUpstream('slack');
vi.mocked(upstream.send).mockRejectedValue(new Error('timeout'));
monitor.track(upstream);
await monitor.checkAll();
expect(monitor.getStatus('slack')?.state).toBe('degraded');
expect(monitor.getStatus('slack')?.consecutiveFailures).toBe(1);
});
it('marks upstream disconnected after threshold failures', async () => {
const upstream = mockUpstream('slack');
vi.mocked(upstream.send).mockRejectedValue(new Error('timeout'));
monitor.track(upstream);
await monitor.checkAll();
await monitor.checkAll();
expect(monitor.getStatus('slack')?.state).toBe('disconnected');
expect(monitor.getStatus('slack')?.consecutiveFailures).toBe(2);
});
it('recovers from degraded to healthy', async () => {
const upstream = mockUpstream('slack');
vi.mocked(upstream.send).mockRejectedValueOnce(new Error('timeout'));
monitor.track(upstream);
await monitor.checkAll();
expect(monitor.getStatus('slack')?.state).toBe('degraded');
// Next check succeeds
await monitor.checkAll();
expect(monitor.getStatus('slack')?.state).toBe('healthy');
expect(monitor.getStatus('slack')?.consecutiveFailures).toBe(0);
});
it('emits change events on state transitions', async () => {
const upstream = mockUpstream('slack');
vi.mocked(upstream.send).mockRejectedValue(new Error('timeout'));
monitor.track(upstream);
const changes: Array<{ name: string; oldState: string; newState: string }> = [];
monitor.on('change', (change) => changes.push(change));
await monitor.checkAll();
expect(changes).toHaveLength(1);
expect(changes[0]).toEqual({ name: 'slack', oldState: 'healthy', newState: 'degraded' });
await monitor.checkAll();
expect(changes).toHaveLength(2);
expect(changes[1]).toEqual({ name: 'slack', oldState: 'degraded', newState: 'disconnected' });
});
it('does not emit when state stays the same', async () => {
const upstream = mockUpstream('slack');
monitor.track(upstream);
const changes: unknown[] = [];
monitor.on('change', (change) => changes.push(change));
await monitor.checkAll();
await monitor.checkAll();
expect(changes).toHaveLength(0);
});
it('reports disconnected when upstream is not alive', async () => {
const upstream = mockUpstream('slack');
monitor.track(upstream);
vi.mocked(upstream.isAlive).mockReturnValue(false);
const changes: Array<{ name: string; oldState: string; newState: string }> = [];
monitor.on('change', (change) => changes.push(change));
await monitor.checkAll();
expect(monitor.getStatus('slack')?.state).toBe('disconnected');
expect(changes).toHaveLength(1);
});
it('returns all statuses', () => {
monitor.track(mockUpstream('slack'));
monitor.track(mockUpstream('github'));
const statuses = monitor.getAllStatuses();
expect(statuses).toHaveLength(2);
expect(statuses.map((s) => s.name)).toEqual(['slack', 'github']);
});
it('untracks upstream', () => {
monitor.track(mockUpstream('slack'));
monitor.untrack('slack');
expect(monitor.getStatus('slack')).toBeUndefined();
expect(monitor.getAllStatuses()).toHaveLength(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
import { describe, it, expect, vi } from 'vitest';
import { LlmProcessor, DEFAULT_PROCESSOR_CONFIG } from '../src/llm/processor.js';
import { ProviderRegistry } from '../src/providers/registry.js';
import type { LlmProvider, CompletionResult } from '../src/providers/types.js';
function mockProvider(responses: string[]): LlmProvider {
let callIndex = 0;
return {
name: 'mock',
async complete(): Promise<CompletionResult> {
const content = responses[callIndex] ?? '{}';
callIndex++;
return {
content,
toolCalls: [],
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
finishReason: 'stop',
};
},
async listModels() { return ['mock-1']; },
async isAvailable() { return true; },
};
}
function makeRegistry(provider?: LlmProvider): ProviderRegistry {
const registry = new ProviderRegistry();
if (provider) {
registry.register(provider);
}
return registry;
}
describe('LlmProcessor.shouldProcess', () => {
it('bypasses protocol-level methods', () => {
const proc = new LlmProcessor(makeRegistry());
expect(proc.shouldProcess('initialize')).toBe(false);
expect(proc.shouldProcess('tools/list')).toBe(false);
expect(proc.shouldProcess('resources/list')).toBe(false);
expect(proc.shouldProcess('prompts/list')).toBe(false);
});
it('returns false when no tool name', () => {
const proc = new LlmProcessor(makeRegistry());
expect(proc.shouldProcess('tools/call')).toBe(false);
});
it('returns true for normal tool calls', () => {
const proc = new LlmProcessor(makeRegistry());
expect(proc.shouldProcess('tools/call', 'slack/search_messages')).toBe(true);
});
it('skips excluded tools', () => {
const proc = new LlmProcessor(makeRegistry(), {
...DEFAULT_PROCESSOR_CONFIG,
excludeTools: ['slack'],
});
expect(proc.shouldProcess('tools/call', 'slack/search_messages')).toBe(false);
expect(proc.shouldProcess('tools/call', 'github/search')).toBe(true);
});
it('skips simple CRUD operations', () => {
const proc = new LlmProcessor(makeRegistry());
expect(proc.shouldProcess('tools/call', 'slack/create_channel')).toBe(false);
expect(proc.shouldProcess('tools/call', 'slack/delete_message')).toBe(false);
expect(proc.shouldProcess('tools/call', 'slack/remove_user')).toBe(false);
});
});
describe('LlmProcessor.preprocessRequest', () => {
it('returns original params when preprocessing disabled', async () => {
const proc = new LlmProcessor(makeRegistry(mockProvider(['{}'])), {
...DEFAULT_PROCESSOR_CONFIG,
enablePreprocessing: false,
});
const result = await proc.preprocessRequest('slack/search', { query: 'test' });
expect(result.optimized).toBe(false);
expect(result.params).toEqual({ query: 'test' });
});
it('returns original params when no provider', async () => {
const proc = new LlmProcessor(makeRegistry(), {
...DEFAULT_PROCESSOR_CONFIG,
enablePreprocessing: true,
});
const result = await proc.preprocessRequest('slack/search', { query: 'test' });
expect(result.optimized).toBe(false);
});
it('optimizes params with LLM', async () => {
const provider = mockProvider([JSON.stringify({ query: 'test', limit: 10 })]);
const proc = new LlmProcessor(makeRegistry(provider), {
...DEFAULT_PROCESSOR_CONFIG,
enablePreprocessing: true,
});
const result = await proc.preprocessRequest('slack/search', { query: 'test' });
expect(result.optimized).toBe(true);
expect(result.params).toEqual({ query: 'test', limit: 10 });
});
it('falls back on LLM error', async () => {
const badProvider: LlmProvider = {
name: 'bad',
async complete() { throw new Error('LLM down'); },
async listModels() { return []; },
async isAvailable() { return false; },
};
const proc = new LlmProcessor(makeRegistry(badProvider), {
...DEFAULT_PROCESSOR_CONFIG,
enablePreprocessing: true,
});
const result = await proc.preprocessRequest('slack/search', { query: 'test' });
expect(result.optimized).toBe(false);
expect(result.params).toEqual({ query: 'test' });
});
});
describe('LlmProcessor.filterResponse', () => {
it('returns original when filtering disabled', async () => {
const proc = new LlmProcessor(makeRegistry(mockProvider([])), {
...DEFAULT_PROCESSOR_CONFIG,
enableFiltering: false,
});
const response = { jsonrpc: '2.0' as const, id: '1', result: { data: 'big' } };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(false);
});
it('returns original when no provider', async () => {
const proc = new LlmProcessor(makeRegistry());
const response = { jsonrpc: '2.0' as const, id: '1', result: { data: 'x'.repeat(600) } };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(false);
});
it('skips small responses below token threshold', async () => {
const proc = new LlmProcessor(makeRegistry(mockProvider([])));
// With default tokenThreshold=250, any response < 1000 chars (~250 tokens) is skipped
const response = { jsonrpc: '2.0' as const, id: '1', result: { data: 'small' } };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(false);
});
it('skips error responses', async () => {
const proc = new LlmProcessor(makeRegistry(mockProvider([])));
const response = { jsonrpc: '2.0' as const, id: '1', error: { code: -1, message: 'fail' } };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(false);
});
it('filters large responses with LLM', async () => {
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, name: `item-${i}`, extra: 'x'.repeat(20) })) };
const filteredData = { items: [{ id: 0, name: 'item-0' }, { id: 1, name: 'item-1' }] };
const provider = mockProvider([JSON.stringify(filteredData)]);
const proc = new LlmProcessor(makeRegistry(provider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(true);
expect(result.filteredSize).toBeLessThan(result.originalSize);
});
it('falls back on LLM error', async () => {
const badProvider: LlmProvider = {
name: 'bad',
async complete() { throw new Error('LLM down'); },
async listModels() { return []; },
async isAvailable() { return false; },
};
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, extra: 'x'.repeat(20) })) };
const proc = new LlmProcessor(makeRegistry(badProvider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(false);
expect(result.result).toEqual(largeData);
});
it('respects custom tokenThreshold', async () => {
// Set a very high threshold so that even "big" responses are skipped
const proc = new LlmProcessor(makeRegistry(mockProvider([])), {
...DEFAULT_PROCESSOR_CONFIG,
tokenThreshold: 10_000,
});
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, name: `item-${i}` })) };
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
const result = await proc.filterResponse('slack/search', response);
expect(result.filtered).toBe(false);
});
it('uses filter cache to skip repeated filtering', async () => {
// First call: LLM returns same-size data => cache records shouldFilter=false
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, extra: 'x'.repeat(20) })) };
const raw = JSON.stringify(largeData);
// Return something larger so the cache stores shouldFilter=false (filtered not smaller)
const notSmaller = JSON.stringify(largeData);
const provider = mockProvider([notSmaller]);
const proc = new LlmProcessor(makeRegistry(provider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
// First call goes to LLM
await proc.filterResponse('slack/search', response);
// Second call should hit cache (shouldFilter=false) and skip LLM
const result2 = await proc.filterResponse('slack/search', response);
expect(result2.filtered).toBe(false);
const metrics = proc.getMetrics();
expect(metrics.cacheHits).toBeGreaterThanOrEqual(1);
});
it('records metrics on filter operations', async () => {
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, name: `item-${i}`, extra: 'x'.repeat(20) })) };
const filteredData = { items: [{ id: 0, name: 'item-0' }] };
const provider = mockProvider([JSON.stringify(filteredData)]);
const proc = new LlmProcessor(makeRegistry(provider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
await proc.filterResponse('slack/search', response);
const metrics = proc.getMetrics();
expect(metrics.filterCount).toBe(1);
expect(metrics.totalTokensProcessed).toBeGreaterThan(0);
expect(metrics.tokensSaved).toBeGreaterThan(0);
expect(metrics.cacheMisses).toBe(1);
});
it('records metrics even on LLM failure', async () => {
const badProvider: LlmProvider = {
name: 'bad',
async complete() { throw new Error('LLM down'); },
async listModels() { return []; },
async isAvailable() { return false; },
};
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, extra: 'x'.repeat(20) })) };
const proc = new LlmProcessor(makeRegistry(badProvider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
await proc.filterResponse('slack/search', response);
const metrics = proc.getMetrics();
expect(metrics.filterCount).toBe(1);
expect(metrics.totalTokensProcessed).toBeGreaterThan(0);
// No tokens saved because filter failed
expect(metrics.tokensSaved).toBe(0);
});
});
describe('LlmProcessor metrics and cache management', () => {
it('exposes metrics via getMetrics()', () => {
const proc = new LlmProcessor(makeRegistry());
const metrics = proc.getMetrics();
expect(metrics.totalTokensProcessed).toBe(0);
expect(metrics.filterCount).toBe(0);
});
it('resets metrics', async () => {
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, extra: 'x'.repeat(20) })) };
const provider = mockProvider([JSON.stringify({ summary: 'ok' })]);
const proc = new LlmProcessor(makeRegistry(provider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
await proc.filterResponse('slack/search', response);
expect(proc.getMetrics().filterCount).toBe(1);
proc.resetMetrics();
expect(proc.getMetrics().filterCount).toBe(0);
});
it('clears filter cache', async () => {
const largeData = { items: Array.from({ length: 50 }, (_, i) => ({ id: i, extra: 'x'.repeat(20) })) };
const filteredData = { items: [{ id: 0 }] };
// Two responses needed: first call filters, second call after cache clear also filters
const provider = mockProvider([JSON.stringify(filteredData), JSON.stringify(filteredData)]);
const proc = new LlmProcessor(makeRegistry(provider));
const response = { jsonrpc: '2.0' as const, id: '1', result: largeData };
await proc.filterResponse('slack/search', response);
proc.clearFilterCache();
// After clearing cache, should get a cache miss again
proc.resetMetrics();
await proc.filterResponse('slack/search', response);
expect(proc.getMetrics().cacheMisses).toBe(1);
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi } from 'vitest';
import { McpdUpstream } from '../src/upstream/mcpd.js';
import type { JsonRpcRequest } from '../src/types.js';
function mockMcpdClient(responses: Map<string, unknown> = new Map()) {
return {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(),
post: vi.fn(async (_path: string, body: unknown) => {
const req = body as { serverId: string; method: string };
const key = `${req.serverId}:${req.method}`;
if (responses.has(key)) {
return responses.get(key);
}
return { result: { ok: true } };
}),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(),
};
}
describe('McpdUpstream', () => {
it('sends tool calls via mcpd proxy', async () => {
const client = mockMcpdClient(new Map([
['srv-1:tools/call', { result: { content: [{ type: 'text', text: 'hello' }] } }],
]));
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '1',
method: 'tools/call',
params: { name: 'search', arguments: { query: 'test' } },
};
const response = await upstream.send(request);
expect(response.result).toEqual({ content: [{ type: 'text', text: 'hello' }] });
expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', {
serverId: 'srv-1',
method: 'tools/call',
params: { name: 'search', arguments: { query: 'test' } },
});
});
it('sends tools/list via mcpd proxy', async () => {
const client = mockMcpdClient(new Map([
['srv-1:tools/list', { result: { tools: [{ name: 'search', description: 'Search' }] } }],
]));
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '2',
method: 'tools/list',
};
const response = await upstream.send(request);
expect(response.result).toEqual({ tools: [{ name: 'search', description: 'Search' }] });
});
it('returns error when mcpd fails', async () => {
const client = mockMcpdClient();
client.post.mockRejectedValue(new Error('connection refused'));
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
const request: JsonRpcRequest = { jsonrpc: '2.0', id: '3', method: 'tools/list' };
const response = await upstream.send(request);
expect(response.error).toBeDefined();
expect(response.error!.message).toContain('mcpd proxy error');
});
it('returns error when upstream is closed', async () => {
const client = mockMcpdClient();
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
await upstream.close();
const request: JsonRpcRequest = { jsonrpc: '2.0', id: '4', method: 'tools/list' };
const response = await upstream.send(request);
expect(response.error).toBeDefined();
expect(response.error!.message).toContain('closed');
});
it('reports alive status correctly', async () => {
const client = mockMcpdClient();
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
expect(upstream.isAlive()).toBe(true);
await upstream.close();
expect(upstream.isAlive()).toBe(false);
});
it('relays error responses from mcpd', async () => {
const client = mockMcpdClient(new Map([
['srv-1:tools/call', { error: { code: -32601, message: 'Tool not found' } }],
]));
const upstream = new McpdUpstream('srv-1', 'slack', client as any);
const request: JsonRpcRequest = {
jsonrpc: '2.0',
id: '5',
method: 'tools/call',
params: { name: 'nonexistent' },
};
const response = await upstream.send(request);
expect(response.error).toEqual({ code: -32601, message: 'Tool not found' });
});
});

View File

@@ -0,0 +1,93 @@
import { describe, it, expect } from 'vitest';
import { FilterMetrics } from '../src/llm/metrics.js';
describe('FilterMetrics', () => {
it('starts with zeroed stats', () => {
const m = new FilterMetrics();
const stats = m.getStats();
expect(stats.totalTokensProcessed).toBe(0);
expect(stats.tokensSaved).toBe(0);
expect(stats.cacheHits).toBe(0);
expect(stats.cacheMisses).toBe(0);
expect(stats.filterCount).toBe(0);
expect(stats.averageFilterLatencyMs).toBe(0);
});
it('records filter operations and accumulates tokens', () => {
const m = new FilterMetrics();
m.recordFilter(500, 200, 50);
m.recordFilter(300, 100, 30);
const stats = m.getStats();
expect(stats.totalTokensProcessed).toBe(800);
expect(stats.tokensSaved).toBe(500); // (500-200) + (300-100)
expect(stats.filterCount).toBe(2);
expect(stats.averageFilterLatencyMs).toBe(40); // (50+30)/2
});
it('does not allow negative token savings', () => {
const m = new FilterMetrics();
// Filtered output is larger than original (edge case)
m.recordFilter(100, 200, 10);
const stats = m.getStats();
expect(stats.totalTokensProcessed).toBe(100);
expect(stats.tokensSaved).toBe(0); // clamped to 0
});
it('records cache hits and misses independently', () => {
const m = new FilterMetrics();
m.recordCacheHit();
m.recordCacheHit();
m.recordCacheMiss();
const stats = m.getStats();
expect(stats.cacheHits).toBe(2);
expect(stats.cacheMisses).toBe(1);
});
it('computes average latency correctly', () => {
const m = new FilterMetrics();
m.recordFilter(100, 50, 10);
m.recordFilter(100, 50, 20);
m.recordFilter(100, 50, 30);
expect(m.getStats().averageFilterLatencyMs).toBe(20);
});
it('returns 0 average latency when no filter operations', () => {
const m = new FilterMetrics();
// Only cache operations, no filter calls
m.recordCacheHit();
expect(m.getStats().averageFilterLatencyMs).toBe(0);
});
it('resets all metrics to zero', () => {
const m = new FilterMetrics();
m.recordFilter(500, 200, 50);
m.recordCacheHit();
m.recordCacheMiss();
m.reset();
const stats = m.getStats();
expect(stats.totalTokensProcessed).toBe(0);
expect(stats.tokensSaved).toBe(0);
expect(stats.cacheHits).toBe(0);
expect(stats.cacheMisses).toBe(0);
expect(stats.filterCount).toBe(0);
expect(stats.averageFilterLatencyMs).toBe(0);
});
it('returns independent snapshots', () => {
const m = new FilterMetrics();
m.recordFilter(100, 50, 10);
const snap1 = m.getStats();
m.recordFilter(200, 100, 20);
const snap2 = m.getStats();
// snap1 should not have been mutated
expect(snap1.totalTokensProcessed).toBe(100);
expect(snap2.totalTokensProcessed).toBe(300);
});
});

View File

@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProviderRegistry } from '../src/providers/registry.js';
import type { LlmProvider, CompletionOptions, CompletionResult } from '../src/providers/types.js';
function mockProvider(name: string): LlmProvider {
return {
name,
complete: vi.fn(async (): Promise<CompletionResult> => ({
content: `Response from ${name}`,
toolCalls: [],
usage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
finishReason: 'stop',
})),
listModels: vi.fn(async () => [`${name}-model-1`, `${name}-model-2`]),
isAvailable: vi.fn(async () => true),
};
}
describe('ProviderRegistry', () => {
let registry: ProviderRegistry;
beforeEach(() => {
registry = new ProviderRegistry();
});
it('starts with no providers', () => {
expect(registry.list()).toEqual([]);
expect(registry.getActive()).toBeNull();
expect(registry.getActiveName()).toBeNull();
});
it('registers a provider and sets it as active', () => {
const openai = mockProvider('openai');
registry.register(openai);
expect(registry.list()).toEqual(['openai']);
expect(registry.getActive()).toBe(openai);
expect(registry.getActiveName()).toBe('openai');
});
it('first registered provider becomes active', () => {
registry.register(mockProvider('openai'));
registry.register(mockProvider('anthropic'));
expect(registry.getActiveName()).toBe('openai');
expect(registry.list()).toEqual(['openai', 'anthropic']);
});
it('switches active provider', () => {
registry.register(mockProvider('openai'));
registry.register(mockProvider('anthropic'));
registry.setActive('anthropic');
expect(registry.getActiveName()).toBe('anthropic');
});
it('throws when setting unknown provider as active', () => {
expect(() => registry.setActive('unknown')).toThrow("Provider 'unknown' is not registered");
});
it('gets provider by name', () => {
const openai = mockProvider('openai');
registry.register(openai);
expect(registry.get('openai')).toBe(openai);
expect(registry.get('unknown')).toBeUndefined();
});
it('unregisters a provider', () => {
registry.register(mockProvider('openai'));
registry.register(mockProvider('anthropic'));
registry.unregister('openai');
expect(registry.list()).toEqual(['anthropic']);
// Active should switch to remaining provider
expect(registry.getActiveName()).toBe('anthropic');
});
it('unregistering active provider switches to next available', () => {
registry.register(mockProvider('openai'));
registry.register(mockProvider('anthropic'));
registry.setActive('openai');
registry.unregister('openai');
expect(registry.getActiveName()).toBe('anthropic');
});
it('unregistering last provider clears active', () => {
registry.register(mockProvider('openai'));
registry.unregister('openai');
expect(registry.getActive()).toBeNull();
expect(registry.getActiveName()).toBeNull();
});
it('active provider can complete', async () => {
const provider = mockProvider('openai');
registry.register(provider);
const active = registry.getActive()!;
const result = await active.complete({
messages: [{ role: 'user', content: 'Hello' }],
});
expect(result.content).toBe('Response from openai');
expect(result.finishReason).toBe('stop');
expect(provider.complete).toHaveBeenCalled();
});
it('active provider can list models', async () => {
registry.register(mockProvider('anthropic'));
const active = registry.getActive()!;
const models = await active.listModels();
expect(models).toEqual(['anthropic-model-1', 'anthropic-model-2']);
});
});

View File

@@ -0,0 +1,448 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpRouter } from '../src/router.js';
import type { UpstreamConnection, JsonRpcRequest, JsonRpcResponse, JsonRpcNotification } from '../src/types.js';
function mockUpstream(
name: string,
opts: {
tools?: Array<{ name: string; description?: string }>;
resources?: Array<{ uri: string; name?: string; description?: string }>;
prompts?: Array<{ name: string; description?: string }>;
} = {},
): UpstreamConnection {
const notificationHandlers: Array<(n: JsonRpcNotification) => void> = [];
return {
name,
isAlive: vi.fn(() => true),
close: vi.fn(async () => {}),
onNotification: vi.fn((handler: (n: JsonRpcNotification) => void) => {
notificationHandlers.push(handler);
}),
send: vi.fn(async (req: JsonRpcRequest): Promise<JsonRpcResponse> => {
if (req.method === 'tools/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { tools: opts.tools ?? [] },
};
}
if (req.method === 'resources/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { resources: opts.resources ?? [] },
};
}
if (req.method === 'prompts/list') {
return {
jsonrpc: '2.0',
id: req.id,
result: { prompts: opts.prompts ?? [] },
};
}
if (req.method === 'tools/call') {
return {
jsonrpc: '2.0',
id: req.id,
result: {
content: [{ type: 'text', text: `Called ${(req.params as Record<string, unknown>)?.name}` }],
},
};
}
if (req.method === 'resources/read') {
return {
jsonrpc: '2.0',
id: req.id,
result: {
contents: [{ uri: (req.params as Record<string, unknown>)?.uri, text: 'resource content' }],
},
};
}
if (req.method === 'prompts/get') {
return {
jsonrpc: '2.0',
id: req.id,
result: {
messages: [{ role: 'user', content: { type: 'text', text: 'prompt content' } }],
},
};
}
return { jsonrpc: '2.0', id: req.id, error: { code: -32601, message: 'Not found' } };
}),
// expose for tests
_notificationHandlers: notificationHandlers,
} as UpstreamConnection & { _notificationHandlers: Array<(n: JsonRpcNotification) => void> };
}
describe('McpRouter', () => {
let router: McpRouter;
beforeEach(() => {
router = new McpRouter();
});
describe('initialize', () => {
it('responds with server info and capabilities including resources and prompts', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
});
expect(res.result).toBeDefined();
const result = res.result as Record<string, unknown>;
expect(result['protocolVersion']).toBe('2024-11-05');
expect((result['serverInfo'] as Record<string, unknown>)['name']).toBe('mcpctl-proxy');
const capabilities = result['capabilities'] as Record<string, unknown>;
expect(capabilities['tools']).toBeDefined();
expect(capabilities['resources']).toBeDefined();
expect(capabilities['prompts']).toBeDefined();
});
});
describe('tools/list', () => {
it('returns empty tools when no upstreams', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
});
const result = res.result as { tools: unknown[] };
expect(result.tools).toEqual([]);
});
it('discovers and namespaces tools from upstreams', async () => {
router.addUpstream(mockUpstream('slack', {
tools: [
{ name: 'send_message', description: 'Send a message' },
{ name: 'list_channels', description: 'List channels' },
],
}));
router.addUpstream(mockUpstream('github', {
tools: [
{ name: 'create_issue', description: 'Create an issue' },
],
}));
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
});
const result = res.result as { tools: Array<{ name: string }> };
expect(result.tools).toHaveLength(3);
expect(result.tools.map((t) => t.name)).toContain('slack/send_message');
expect(result.tools.map((t) => t.name)).toContain('slack/list_channels');
expect(result.tools.map((t) => t.name)).toContain('github/create_issue');
});
it('skips unavailable upstreams', async () => {
const failingUpstream = mockUpstream('failing');
vi.mocked(failingUpstream.send).mockRejectedValue(new Error('Connection refused'));
router.addUpstream(failingUpstream);
router.addUpstream(mockUpstream('working', {
tools: [{ name: 'do_thing', description: 'Does a thing' }],
}));
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/list',
});
const result = res.result as { tools: Array<{ name: string }> };
expect(result.tools).toHaveLength(1);
expect(result.tools[0]?.name).toBe('working/do_thing');
});
});
describe('tools/call', () => {
it('routes call to correct upstream', async () => {
const slack = mockUpstream('slack', { tools: [{ name: 'send_message' }] });
router.addUpstream(slack);
await router.discoverTools();
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'slack/send_message', arguments: { channel: '#general', text: 'hello' } },
});
expect(res.result).toBeDefined();
// Verify the upstream received the call with de-namespaced tool name
expect(vi.mocked(slack.send)).toHaveBeenCalledWith(
expect.objectContaining({
method: 'tools/call',
params: expect.objectContaining({ name: 'send_message' }),
}),
);
});
it('returns error for unknown tool', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'unknown/tool' },
});
expect(res.error).toBeDefined();
expect(res.error?.code).toBe(-32601);
});
it('returns error when tool name is missing', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: {},
});
expect(res.error).toBeDefined();
expect(res.error?.code).toBe(-32602);
});
it('returns error when upstream is dead', async () => {
const slack = mockUpstream('slack', { tools: [{ name: 'send_message' }] });
router.addUpstream(slack);
await router.discoverTools();
vi.mocked(slack.isAlive).mockReturnValue(false);
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name: 'slack/send_message' },
});
expect(res.error).toBeDefined();
expect(res.error?.code).toBe(-32603);
});
});
describe('resources/list', () => {
it('returns empty resources when no upstreams', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'resources/list',
});
const result = res.result as { resources: unknown[] };
expect(result.resources).toEqual([]);
});
it('discovers and namespaces resources from upstreams', async () => {
router.addUpstream(mockUpstream('files', {
resources: [
{ uri: 'file:///docs/readme.md', name: 'README', description: 'Project readme' },
],
}));
router.addUpstream(mockUpstream('db', {
resources: [
{ uri: 'db://users', name: 'Users table' },
],
}));
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'resources/list',
});
const result = res.result as { resources: Array<{ uri: string }> };
expect(result.resources).toHaveLength(2);
expect(result.resources.map((r) => r.uri)).toContain('files://file:///docs/readme.md');
expect(result.resources.map((r) => r.uri)).toContain('db://db://users');
});
});
describe('resources/read', () => {
it('routes read to correct upstream', async () => {
const files = mockUpstream('files', {
resources: [{ uri: 'file:///docs/readme.md', name: 'README' }],
});
router.addUpstream(files);
await router.discoverResources();
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'resources/read',
params: { uri: 'files://file:///docs/readme.md' },
});
expect(res.result).toBeDefined();
expect(vi.mocked(files.send)).toHaveBeenCalledWith(
expect.objectContaining({
method: 'resources/read',
params: expect.objectContaining({ uri: 'file:///docs/readme.md' }),
}),
);
});
it('returns error for unknown resource', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'resources/read',
params: { uri: 'unknown://resource' },
});
expect(res.error).toBeDefined();
expect(res.error?.code).toBe(-32601);
});
});
describe('prompts/list', () => {
it('returns empty prompts when no upstreams', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'prompts/list',
});
const result = res.result as { prompts: unknown[] };
expect(result.prompts).toEqual([]);
});
it('discovers and namespaces prompts from upstreams', async () => {
router.addUpstream(mockUpstream('code', {
prompts: [
{ name: 'review', description: 'Code review' },
{ name: 'explain', description: 'Explain code' },
],
}));
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'prompts/list',
});
const result = res.result as { prompts: Array<{ name: string }> };
expect(result.prompts).toHaveLength(2);
expect(result.prompts.map((p) => p.name)).toContain('code/review');
expect(result.prompts.map((p) => p.name)).toContain('code/explain');
});
});
describe('prompts/get', () => {
it('routes get to correct upstream', async () => {
const code = mockUpstream('code', {
prompts: [{ name: 'review', description: 'Code review' }],
});
router.addUpstream(code);
await router.discoverPrompts();
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'prompts/get',
params: { name: 'code/review' },
});
expect(res.result).toBeDefined();
expect(vi.mocked(code.send)).toHaveBeenCalledWith(
expect.objectContaining({
method: 'prompts/get',
params: expect.objectContaining({ name: 'review' }),
}),
);
});
it('returns error for unknown prompt', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'prompts/get',
params: { name: 'unknown/prompt' },
});
expect(res.error).toBeDefined();
expect(res.error?.code).toBe(-32601);
});
});
describe('notifications', () => {
it('forwards notifications from upstreams with source tag', () => {
const received: JsonRpcNotification[] = [];
router.setNotificationHandler((n) => received.push(n));
const slack = mockUpstream('slack', { tools: [{ name: 'send_message' }] }) as UpstreamConnection & {
_notificationHandlers: Array<(n: JsonRpcNotification) => void>;
};
router.addUpstream(slack);
// Simulate upstream sending a notification
for (const handler of slack._notificationHandlers) {
handler({ jsonrpc: '2.0', method: 'notifications/progress', params: { progress: 50 } });
}
expect(received).toHaveLength(1);
expect(received[0]?.method).toBe('notifications/progress');
expect(received[0]?.params?._source).toBe('slack');
});
});
describe('unknown methods', () => {
it('returns method not found error', async () => {
const res = await router.route({
jsonrpc: '2.0',
id: 1,
method: 'completions/complete',
});
expect(res.error).toBeDefined();
expect(res.error?.code).toBe(-32601);
});
});
describe('upstream management', () => {
it('lists upstream names', () => {
router.addUpstream(mockUpstream('slack'));
router.addUpstream(mockUpstream('github'));
expect(router.getUpstreamNames()).toEqual(['slack', 'github']);
});
it('removes upstream and cleans up all mappings', async () => {
const slack = mockUpstream('slack', {
tools: [{ name: 'send_message' }],
resources: [{ uri: 'slack://channels' }],
prompts: [{ name: 'compose' }],
});
router.addUpstream(slack);
await router.discoverTools();
await router.discoverResources();
await router.discoverPrompts();
router.removeUpstream('slack');
expect(router.getUpstreamNames()).toEqual([]);
// Verify tool/resource/prompt mappings are cleaned
const toolRes = await router.route({
jsonrpc: '2.0', id: 1, method: 'tools/call',
params: { name: 'slack/send_message' },
});
expect(toolRes.error?.code).toBe(-32601);
});
it('closes all upstreams', async () => {
const slack = mockUpstream('slack');
const github = mockUpstream('github');
router.addUpstream(slack);
router.addUpstream(github);
await router.closeAll();
expect(slack.close).toHaveBeenCalled();
expect(github.close).toHaveBeenCalled();
expect(router.getUpstreamNames()).toEqual([]);
});
});
});

View File

@@ -0,0 +1,304 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TieredHealthMonitor } from '../src/health/tiered.js';
import type { TieredHealthMonitorDeps } from '../src/health/tiered.js';
import type { McpdClient } from '../src/http/mcpd-client.js';
import { ProviderRegistry } from '../src/providers/registry.js';
import type { LlmProvider } from '../src/providers/types.js';
function mockMcpdClient(overrides?: {
getResult?: unknown;
getFails?: boolean;
instancesResult?: { instances: Array<{ name: string; status: string }> };
instancesFails?: boolean;
}): McpdClient {
const client = {
get: vi.fn(async (path: string) => {
if (path === '/health') {
if (overrides?.getFails) {
throw new Error('Connection refused');
}
return overrides?.getResult ?? { status: 'ok' };
}
if (path === '/instances') {
if (overrides?.instancesFails) {
throw new Error('Connection refused');
}
return overrides?.instancesResult ?? { instances: [] };
}
return {};
}),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(),
} as unknown as McpdClient;
return client;
}
function mockLlmProvider(name: string): LlmProvider {
return {
name,
complete: vi.fn(),
listModels: vi.fn(async () => []),
isAvailable: vi.fn(async () => true),
};
}
describe('TieredHealthMonitor', () => {
let providerRegistry: ProviderRegistry;
beforeEach(() => {
providerRegistry = new ProviderRegistry();
});
describe('mcplocal health', () => {
it('reports healthy status with uptime', async () => {
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcplocal.status).toBe('healthy');
expect(result.mcplocal.uptime).toBeGreaterThanOrEqual(0);
});
it('reports null llmProvider when none registered', async () => {
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcplocal.llmProvider).toBeNull();
});
it('reports active llmProvider name when one is registered', async () => {
const provider = mockLlmProvider('openai');
providerRegistry.register(provider);
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcplocal.llmProvider).toBe('openai');
});
it('reports the currently active provider when multiple registered', async () => {
providerRegistry.register(mockLlmProvider('openai'));
providerRegistry.register(mockLlmProvider('anthropic'));
providerRegistry.setActive('anthropic');
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcplocal.llmProvider).toBe('anthropic');
});
});
describe('mcpd health', () => {
it('reports connected when mcpd /health responds successfully', async () => {
const client = mockMcpdClient();
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcpd.status).toBe('connected');
expect(result.mcpd.url).toBe('http://localhost:3100');
});
it('reports disconnected when mcpd /health throws', async () => {
const client = mockMcpdClient({ getFails: true });
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcpd.status).toBe('disconnected');
expect(result.mcpd.url).toBe('http://localhost:3100');
});
it('reports disconnected when mcpdClient is null', async () => {
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcpd.status).toBe('disconnected');
expect(result.mcpd.url).toBe('http://localhost:3100');
});
it('includes the configured mcpd URL in the response', async () => {
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://custom-host:9999',
});
const result = await monitor.checkHealth();
expect(result.mcpd.url).toBe('http://custom-host:9999');
});
});
describe('instances', () => {
it('returns instances from mcpd /instances endpoint', async () => {
const client = mockMcpdClient({
instancesResult: {
instances: [
{ name: 'slack', status: 'running' },
{ name: 'github', status: 'stopped' },
],
},
});
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.instances).toHaveLength(2);
expect(result.instances[0]).toEqual({ name: 'slack', status: 'running' });
expect(result.instances[1]).toEqual({ name: 'github', status: 'stopped' });
});
it('returns empty array when mcpdClient is null', async () => {
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.instances).toEqual([]);
});
it('returns empty array when /instances request fails', async () => {
const client = mockMcpdClient({ instancesFails: true });
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.instances).toEqual([]);
});
it('returns empty array when mcpd has no instances', async () => {
const client = mockMcpdClient({
instancesResult: { instances: [] },
});
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.instances).toEqual([]);
});
});
describe('full integration', () => {
it('returns complete tiered status with all sections', async () => {
providerRegistry.register(mockLlmProvider('openai'));
const client = mockMcpdClient({
instancesResult: {
instances: [
{ name: 'slack', status: 'running' },
],
},
});
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
// Verify structure
expect(result).toHaveProperty('mcplocal');
expect(result).toHaveProperty('mcpd');
expect(result).toHaveProperty('instances');
// mcplocal
expect(result.mcplocal.status).toBe('healthy');
expect(typeof result.mcplocal.uptime).toBe('number');
expect(result.mcplocal.llmProvider).toBe('openai');
// mcpd
expect(result.mcpd.status).toBe('connected');
// instances
expect(result.instances).toHaveLength(1);
expect(result.instances[0]?.name).toBe('slack');
});
it('handles degraded scenario: no mcpd, no provider', async () => {
const monitor = new TieredHealthMonitor({
mcpdClient: null,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcplocal.status).toBe('healthy');
expect(result.mcplocal.llmProvider).toBeNull();
expect(result.mcpd.status).toBe('disconnected');
expect(result.instances).toEqual([]);
});
it('handles mcpd connected but instances endpoint failing', async () => {
const client = mockMcpdClient({ instancesFails: true });
const monitor = new TieredHealthMonitor({
mcpdClient: client,
providerRegistry,
mcpdUrl: 'http://localhost:3100',
});
const result = await monitor.checkHealth();
expect(result.mcpd.status).toBe('connected');
expect(result.instances).toEqual([]);
});
});
});

View File

@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { estimateTokens } from '../src/llm/token-counter.js';
describe('estimateTokens', () => {
it('returns 0 for empty string', () => {
expect(estimateTokens('')).toBe(0);
});
it('returns 1 for strings of 1-4 characters', () => {
expect(estimateTokens('a')).toBe(1);
expect(estimateTokens('ab')).toBe(1);
expect(estimateTokens('abc')).toBe(1);
expect(estimateTokens('abcd')).toBe(1);
});
it('returns 2 for strings of 5-8 characters', () => {
expect(estimateTokens('abcde')).toBe(2);
expect(estimateTokens('abcdefgh')).toBe(2);
});
it('estimates roughly 4 chars per token for longer text', () => {
const text = 'a'.repeat(1000);
expect(estimateTokens(text)).toBe(250);
});
it('rounds up partial tokens', () => {
// 7 chars / 4 = 1.75 -> ceil = 2
expect(estimateTokens('abcdefg')).toBe(2);
// 9 chars / 4 = 2.25 -> ceil = 3
expect(estimateTokens('abcdefghi')).toBe(3);
});
it('handles JSON payloads', () => {
const json = JSON.stringify({ key: 'value', nested: { a: 1, b: [1, 2, 3] } });
const expected = Math.ceil(json.length / 4);
expect(estimateTokens(json)).toBe(expected);
});
it('handles unicode text', () => {
// Note: estimation is by string length (code units), not bytes
const text = '\u{1F600}'.repeat(10); // emoji
const expected = Math.ceil(text.length / 4);
expect(estimateTokens(text)).toBe(expected);
});
});