feat: implement v2 3-tier architecture (mcpctl → mcplocal → mcpd)
- 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:
68
src/mcplocal/tests/discovery.test.ts
Normal file
68
src/mcplocal/tests/discovery.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
src/mcplocal/tests/filter-cache.test.ts
Normal file
112
src/mcplocal/tests/filter-cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
153
src/mcplocal/tests/health.test.ts
Normal file
153
src/mcplocal/tests/health.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
1099
src/mcplocal/tests/integration/e2e-flow.test.ts
Normal file
1099
src/mcplocal/tests/integration/e2e-flow.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
283
src/mcplocal/tests/llm-processor.test.ts
Normal file
283
src/mcplocal/tests/llm-processor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
110
src/mcplocal/tests/mcpd-upstream.test.ts
Normal file
110
src/mcplocal/tests/mcpd-upstream.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
93
src/mcplocal/tests/metrics.test.ts
Normal file
93
src/mcplocal/tests/metrics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
118
src/mcplocal/tests/providers.test.ts
Normal file
118
src/mcplocal/tests/providers.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
448
src/mcplocal/tests/router.test.ts
Normal file
448
src/mcplocal/tests/router.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
304
src/mcplocal/tests/tiered-health.test.ts
Normal file
304
src/mcplocal/tests/tiered-health.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
45
src/mcplocal/tests/token-counter.test.ts
Normal file
45
src/mcplocal/tests/token-counter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user