1100 lines
38 KiB
TypeScript
1100 lines
38 KiB
TypeScript
|
|
/**
|
||
|
|
* End-to-end integration tests for the mcpctl 3-tier architecture.
|
||
|
|
*
|
||
|
|
* These tests wire together the real McpRouter, McpdUpstream, LlmProcessor,
|
||
|
|
* TieredHealthMonitor, and discovery logic against a mock mcpd HTTP server
|
||
|
|
* (node:http) and a mock LLM provider. No Docker or external services needed.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
||
|
|
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
||
|
|
|
||
|
|
import { McpRouter } from '../../src/router.js';
|
||
|
|
import { McpdUpstream } from '../../src/upstream/mcpd.js';
|
||
|
|
import { McpdClient } from '../../src/http/mcpd-client.js';
|
||
|
|
import { LlmProcessor, DEFAULT_PROCESSOR_CONFIG } from '../../src/llm/processor.js';
|
||
|
|
import { ProviderRegistry } from '../../src/providers/registry.js';
|
||
|
|
import { TieredHealthMonitor } from '../../src/health/tiered.js';
|
||
|
|
import { refreshUpstreams } from '../../src/discovery.js';
|
||
|
|
import type { LlmProvider, CompletionResult, CompletionOptions } from '../../src/providers/types.js';
|
||
|
|
import type { JsonRpcRequest } from '../../src/types.js';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Mock mcpd HTTP server
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
interface MockMcpdServerConfig {
|
||
|
|
/** MCP servers that mcpd reports */
|
||
|
|
servers: Array<{ id: string; name: string; transport: string; status?: string | undefined }>;
|
||
|
|
/** Map of "serverId:method" -> response payload */
|
||
|
|
proxyResponses: Map<string, { result?: unknown; error?: { code: number; message: string } }>;
|
||
|
|
/** Instances returned by /api/v1/instances */
|
||
|
|
instances: Array<{ name: string; status: string }>;
|
||
|
|
/** The expected auth token (all requests must carry this) */
|
||
|
|
expectedToken: string;
|
||
|
|
/** Track requests for assertion purposes */
|
||
|
|
requestLog: Array<{
|
||
|
|
method: string;
|
||
|
|
url: string;
|
||
|
|
authHeader: string | undefined;
|
||
|
|
body: unknown;
|
||
|
|
}>;
|
||
|
|
}
|
||
|
|
|
||
|
|
function defaultMockConfig(): MockMcpdServerConfig {
|
||
|
|
return {
|
||
|
|
servers: [],
|
||
|
|
proxyResponses: new Map(),
|
||
|
|
instances: [],
|
||
|
|
expectedToken: 'test-token-12345',
|
||
|
|
requestLog: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Starts a real HTTP server on an ephemeral port that mimics mcpd's API.
|
||
|
|
* Returns the server, its base URL, and the config (mutable for per-test tweaks).
|
||
|
|
*/
|
||
|
|
async function startMockMcpd(
|
||
|
|
configOverrides?: Partial<MockMcpdServerConfig>,
|
||
|
|
): Promise<{ server: Server; baseUrl: string; config: MockMcpdServerConfig }> {
|
||
|
|
const config: MockMcpdServerConfig = { ...defaultMockConfig(), ...configOverrides };
|
||
|
|
|
||
|
|
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
||
|
|
// Collect body
|
||
|
|
const chunks: Buffer[] = [];
|
||
|
|
for await (const chunk of req) {
|
||
|
|
chunks.push(chunk as Buffer);
|
||
|
|
}
|
||
|
|
const bodyStr = Buffer.concat(chunks).toString('utf-8');
|
||
|
|
let body: unknown;
|
||
|
|
try {
|
||
|
|
body = bodyStr ? JSON.parse(bodyStr) : undefined;
|
||
|
|
} catch {
|
||
|
|
body = bodyStr;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Log the request
|
||
|
|
config.requestLog.push({
|
||
|
|
method: req.method ?? 'GET',
|
||
|
|
url: req.url ?? '/',
|
||
|
|
authHeader: req.headers['authorization'],
|
||
|
|
body,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Auth check
|
||
|
|
const auth = req.headers['authorization'];
|
||
|
|
if (auth !== `Bearer ${config.expectedToken}`) {
|
||
|
|
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
const url = req.url ?? '/';
|
||
|
|
|
||
|
|
// Health endpoint
|
||
|
|
if (url === '/health' && req.method === 'GET') {
|
||
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ status: 'ok' }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Servers list
|
||
|
|
if (url === '/api/v1/servers' && req.method === 'GET') {
|
||
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify(config.servers));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Instances (TieredHealthMonitor uses /instances, other code may use /api/v1/instances)
|
||
|
|
if ((url === '/instances' || url === '/api/v1/instances') && req.method === 'GET') {
|
||
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ instances: config.instances }));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// MCP proxy endpoint
|
||
|
|
if (url === '/api/v1/mcp/proxy' && req.method === 'POST') {
|
||
|
|
const proxyReq = body as { serverId: string; method: string; params?: Record<string, unknown> };
|
||
|
|
const key = `${proxyReq.serverId}:${proxyReq.method}`;
|
||
|
|
const resp = config.proxyResponses.get(key);
|
||
|
|
if (resp) {
|
||
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify(resp));
|
||
|
|
} else {
|
||
|
|
// Default: return a generic success
|
||
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ result: { ok: true } }));
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Catch-all 404
|
||
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||
|
|
res.end(JSON.stringify({ error: 'Not found' }));
|
||
|
|
});
|
||
|
|
|
||
|
|
// Listen on random port
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
server.listen(0, '127.0.0.1', resolve);
|
||
|
|
});
|
||
|
|
|
||
|
|
const addr = server.address();
|
||
|
|
if (!addr || typeof addr === 'string') {
|
||
|
|
throw new Error('Failed to get server address');
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
server,
|
||
|
|
baseUrl: `http://127.0.0.1:${String(addr.port)}`,
|
||
|
|
config,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Mock LLM provider
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function createMockLlmProvider(
|
||
|
|
name: string,
|
||
|
|
handler: (options: CompletionOptions) => string,
|
||
|
|
): LlmProvider {
|
||
|
|
return {
|
||
|
|
name,
|
||
|
|
async complete(options: CompletionOptions): Promise<CompletionResult> {
|
||
|
|
const content = handler(options);
|
||
|
|
return {
|
||
|
|
content,
|
||
|
|
toolCalls: [],
|
||
|
|
usage: { promptTokens: 10, completionTokens: 5, totalTokens: 15 },
|
||
|
|
finishReason: 'stop',
|
||
|
|
};
|
||
|
|
},
|
||
|
|
async listModels() {
|
||
|
|
return ['mock-model-1'];
|
||
|
|
},
|
||
|
|
async isAvailable() {
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function createFailingLlmProvider(name: string): LlmProvider {
|
||
|
|
return {
|
||
|
|
name,
|
||
|
|
async complete(): Promise<CompletionResult> {
|
||
|
|
throw new Error('LLM provider unavailable');
|
||
|
|
},
|
||
|
|
async listModels() {
|
||
|
|
return [];
|
||
|
|
},
|
||
|
|
async isAvailable() {
|
||
|
|
return false;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
describe('End-to-end integration: 3-tier architecture', () => {
|
||
|
|
let mockMcpd: { server: Server; baseUrl: string; config: MockMcpdServerConfig };
|
||
|
|
let router: McpRouter;
|
||
|
|
|
||
|
|
afterEach(async () => {
|
||
|
|
if (router) {
|
||
|
|
await router.closeAll();
|
||
|
|
}
|
||
|
|
if (mockMcpd?.server) {
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
mockMcpd.server.close(() => resolve());
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 1. Full tool call flow through McpdUpstream
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Full tool call flow', () => {
|
||
|
|
it('routes a tool call through McpRouter -> McpdUpstream -> mock mcpd and returns the response', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [
|
||
|
|
{ id: 'srv-slack', name: 'slack', transport: 'stdio' },
|
||
|
|
],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-slack:tools/list', {
|
||
|
|
result: {
|
||
|
|
tools: [
|
||
|
|
{ name: 'search_messages', description: 'Search Slack messages', inputSchema: {} },
|
||
|
|
{ name: 'send_message', description: 'Send a message', inputSchema: {} },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
['srv-slack:tools/call', {
|
||
|
|
result: {
|
||
|
|
content: [{ type: 'text', text: 'Found 3 messages matching "deploy"' }],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// Discover servers from mcpd and register them
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
expect(router.getUpstreamNames()).toContain('slack');
|
||
|
|
|
||
|
|
// Discover tools
|
||
|
|
const tools = await router.discoverTools();
|
||
|
|
expect(tools.map((t) => t.name)).toContain('slack/search_messages');
|
||
|
|
expect(tools.map((t) => t.name)).toContain('slack/send_message');
|
||
|
|
|
||
|
|
// Call a tool
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'call-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'slack/search_messages', arguments: { query: 'deploy' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeUndefined();
|
||
|
|
expect(response.result).toEqual({
|
||
|
|
content: [{ type: 'text', text: 'Found 3 messages matching "deploy"' }],
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles tool call that returns an error from the upstream server', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [
|
||
|
|
{ id: 'srv-db', name: 'database', transport: 'stdio' },
|
||
|
|
],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-db:tools/list', {
|
||
|
|
result: { tools: [{ name: 'query', description: 'Run SQL query' }] },
|
||
|
|
}],
|
||
|
|
['srv-db:tools/call', {
|
||
|
|
error: { code: -32000, message: 'Query timeout: exceeded 30s limit' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'call-err',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'database/query', arguments: { sql: 'SELECT * FROM huge_table' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeDefined();
|
||
|
|
expect(response.error?.code).toBe(-32000);
|
||
|
|
expect(response.error?.message).toContain('Query timeout');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 2. Tool call with LLM filtering
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Tool call with LLM filtering', () => {
|
||
|
|
it('filters a large response through the LLM processor and returns reduced data', async () => {
|
||
|
|
// Generate a large response that exceeds the token threshold (~250 tokens = ~1000 chars)
|
||
|
|
const largeItems = Array.from({ length: 100 }, (_, i) => ({
|
||
|
|
id: i,
|
||
|
|
name: `item-${String(i)}`,
|
||
|
|
description: `This is a verbose description for item number ${String(i)} that adds bulk`,
|
||
|
|
metadata: { created: '2025-01-01', tags: ['a', 'b', 'c'] },
|
||
|
|
}));
|
||
|
|
|
||
|
|
const filteredItems = [
|
||
|
|
{ id: 0, name: 'item-0' },
|
||
|
|
{ id: 1, name: 'item-1' },
|
||
|
|
{ id: 2, name: 'item-2' },
|
||
|
|
];
|
||
|
|
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-data', name: 'dataserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-data:tools/list', {
|
||
|
|
result: { tools: [{ name: 'search_records', description: 'Search records' }] },
|
||
|
|
}],
|
||
|
|
['srv-data:tools/call', {
|
||
|
|
result: { items: largeItems },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// Create LLM processor with mock provider that returns filtered data
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
const mockProvider = createMockLlmProvider('test-filter', () => {
|
||
|
|
return JSON.stringify({ items: filteredItems });
|
||
|
|
});
|
||
|
|
registry.register(mockProvider);
|
||
|
|
|
||
|
|
const processor = new LlmProcessor(registry, {
|
||
|
|
...DEFAULT_PROCESSOR_CONFIG,
|
||
|
|
enableFiltering: true,
|
||
|
|
tokenThreshold: 50, // Low threshold so filtering kicks in
|
||
|
|
});
|
||
|
|
router.setLlmProcessor(processor);
|
||
|
|
|
||
|
|
// Call the tool - should get filtered response
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'filter-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'dataserver/search_records', arguments: { query: 'test' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeUndefined();
|
||
|
|
// The result should be the filtered (smaller) version
|
||
|
|
const result = response.result as { items: Array<{ id: number; name: string }> };
|
||
|
|
expect(result.items).toHaveLength(3);
|
||
|
|
expect(result.items[0]?.name).toBe('item-0');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('preserves original response size information in metrics', async () => {
|
||
|
|
const largePayload = Array.from({ length: 80 }, (_, i) => ({
|
||
|
|
id: i,
|
||
|
|
value: 'x'.repeat(30),
|
||
|
|
}));
|
||
|
|
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-big', name: 'bigserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-big:tools/list', {
|
||
|
|
result: { tools: [{ name: 'fetch_data', description: 'Fetch data' }] },
|
||
|
|
}],
|
||
|
|
['srv-big:tools/call', {
|
||
|
|
result: { data: largePayload },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
const mockProvider = createMockLlmProvider('test-metrics', () => {
|
||
|
|
return JSON.stringify({ summary: 'reduced' });
|
||
|
|
});
|
||
|
|
registry.register(mockProvider);
|
||
|
|
|
||
|
|
const processor = new LlmProcessor(registry, {
|
||
|
|
...DEFAULT_PROCESSOR_CONFIG,
|
||
|
|
enableFiltering: true,
|
||
|
|
tokenThreshold: 10,
|
||
|
|
});
|
||
|
|
router.setLlmProcessor(processor);
|
||
|
|
|
||
|
|
await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'metrics-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'bigserver/fetch_data', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
const metrics = processor.getMetrics();
|
||
|
|
expect(metrics.filterCount).toBe(1);
|
||
|
|
expect(metrics.tokensSaved).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 3. Tool call without LLM (bypass)
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Tool call without LLM (bypass)', () => {
|
||
|
|
it('bypasses LLM filtering for simple create/delete operations', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-crud', name: 'crudserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-crud:tools/list', {
|
||
|
|
result: {
|
||
|
|
tools: [
|
||
|
|
{ name: 'create_record', description: 'Create a record' },
|
||
|
|
{ name: 'delete_record', description: 'Delete a record' },
|
||
|
|
{ name: 'search_records', description: 'Search records' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
['srv-crud:tools/call', {
|
||
|
|
result: { success: true, id: 'new-record-123' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// Set up LLM processor - it should bypass create/delete ops
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
let llmCallCount = 0;
|
||
|
|
const mockProvider = createMockLlmProvider('test-bypass', () => {
|
||
|
|
llmCallCount++;
|
||
|
|
return '{}';
|
||
|
|
});
|
||
|
|
registry.register(mockProvider);
|
||
|
|
|
||
|
|
const processor = new LlmProcessor(registry, {
|
||
|
|
...DEFAULT_PROCESSOR_CONFIG,
|
||
|
|
enableFiltering: true,
|
||
|
|
tokenThreshold: 0, // No threshold, always attempt filter
|
||
|
|
});
|
||
|
|
router.setLlmProcessor(processor);
|
||
|
|
|
||
|
|
// Call create_record - should bypass LLM
|
||
|
|
const createResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'bypass-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'crudserver/create_record', arguments: { name: 'test' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(createResponse.error).toBeUndefined();
|
||
|
|
expect(createResponse.result).toEqual({ success: true, id: 'new-record-123' });
|
||
|
|
expect(llmCallCount).toBe(0); // LLM was not called
|
||
|
|
|
||
|
|
// Call delete_record - should also bypass
|
||
|
|
const deleteResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'bypass-2',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'crudserver/delete_record', arguments: { id: 'record-1' } },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(deleteResponse.error).toBeUndefined();
|
||
|
|
expect(llmCallCount).toBe(0); // Still not called
|
||
|
|
});
|
||
|
|
|
||
|
|
it('passes through directly when no LLM processor is set', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-simple', name: 'simpleserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-simple:tools/list', {
|
||
|
|
result: { tools: [{ name: 'get_info', description: 'Get info' }] },
|
||
|
|
}],
|
||
|
|
['srv-simple:tools/call', {
|
||
|
|
result: { info: 'direct passthrough data' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// No LLM processor set - direct passthrough
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'direct-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'simpleserver/get_info', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeUndefined();
|
||
|
|
expect(response.result).toEqual({ info: 'direct passthrough data' });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 4. Server discovery and routing
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Server discovery and routing', () => {
|
||
|
|
it('discovers tools from multiple servers and routes by name prefix', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [
|
||
|
|
{ id: 'srv-slack', name: 'slack', transport: 'stdio' },
|
||
|
|
{ id: 'srv-github', name: 'github', transport: 'stdio' },
|
||
|
|
],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-slack:tools/list', {
|
||
|
|
result: {
|
||
|
|
tools: [
|
||
|
|
{ name: 'search_messages', description: 'Search messages' },
|
||
|
|
{ name: 'post_message', description: 'Post a message' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
['srv-github:tools/list', {
|
||
|
|
result: {
|
||
|
|
tools: [
|
||
|
|
{ name: 'list_issues', description: 'List issues' },
|
||
|
|
{ name: 'search_code', description: 'Search code' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
['srv-slack:tools/call', {
|
||
|
|
result: { source: 'slack', data: 'slack response' },
|
||
|
|
}],
|
||
|
|
['srv-github:tools/call', {
|
||
|
|
result: { source: 'github', data: 'github response' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
|
||
|
|
// Discover all tools
|
||
|
|
const tools = await router.discoverTools();
|
||
|
|
expect(tools).toHaveLength(4);
|
||
|
|
expect(tools.map((t) => t.name).sort()).toEqual([
|
||
|
|
'github/list_issues',
|
||
|
|
'github/search_code',
|
||
|
|
'slack/post_message',
|
||
|
|
'slack/search_messages',
|
||
|
|
]);
|
||
|
|
|
||
|
|
// Route to slack
|
||
|
|
const slackResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'multi-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'slack/search_messages', arguments: { query: 'hello' } },
|
||
|
|
});
|
||
|
|
expect(slackResponse.error).toBeUndefined();
|
||
|
|
expect((slackResponse.result as Record<string, unknown>)['source']).toBe('slack');
|
||
|
|
|
||
|
|
// Route to github
|
||
|
|
const githubResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'multi-2',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'github/list_issues', arguments: { repo: 'test' } },
|
||
|
|
});
|
||
|
|
expect(githubResponse.error).toBeUndefined();
|
||
|
|
expect((githubResponse.result as Record<string, unknown>)['source']).toBe('github');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('refreshUpstreams removes stale servers and adds new ones', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [
|
||
|
|
{ id: 'srv-a', name: 'server-a', transport: 'stdio' },
|
||
|
|
{ id: 'srv-b', name: 'server-b', transport: 'stdio' },
|
||
|
|
],
|
||
|
|
proxyResponses: new Map(),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// First discovery
|
||
|
|
const first = await refreshUpstreams(router, client);
|
||
|
|
expect(first.sort()).toEqual(['server-a', 'server-b']);
|
||
|
|
expect(router.getUpstreamNames().sort()).toEqual(['server-a', 'server-b']);
|
||
|
|
|
||
|
|
// Reconfigure mock: remove server-a, add server-c
|
||
|
|
mockMcpd.config.servers = [
|
||
|
|
{ id: 'srv-b', name: 'server-b', transport: 'stdio' },
|
||
|
|
{ id: 'srv-c', name: 'server-c', transport: 'stdio' },
|
||
|
|
];
|
||
|
|
|
||
|
|
// Second discovery
|
||
|
|
const second = await refreshUpstreams(router, client);
|
||
|
|
expect(second.sort()).toEqual(['server-b', 'server-c']);
|
||
|
|
expect(router.getUpstreamNames().sort()).toEqual(['server-b', 'server-c']);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses the initialize method to return proxy capabilities', async () => {
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'init-1',
|
||
|
|
method: 'initialize',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeUndefined();
|
||
|
|
const result = response.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).toHaveProperty('tools');
|
||
|
|
expect(capabilities).toHaveProperty('resources');
|
||
|
|
expect(capabilities).toHaveProperty('prompts');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 5. Error handling
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Error handling', () => {
|
||
|
|
it('returns graceful error when mcpd is unreachable', async () => {
|
||
|
|
// Create a client pointing to a port that nothing listens on
|
||
|
|
const client = new McpdClient('http://127.0.0.1:1', 'some-token');
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// refreshUpstreams will fail because mcpd is unreachable
|
||
|
|
await expect(refreshUpstreams(router, client)).rejects.toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns error response when mcpd proxy call fails mid-request', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-flaky', name: 'flaky', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-flaky:tools/list', {
|
||
|
|
result: { tools: [{ name: 'do_thing', description: 'Do a thing' }] },
|
||
|
|
}],
|
||
|
|
// No response configured for tools/call -> will get default { result: { ok: true } }
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// Close the mock server to simulate mcpd going down
|
||
|
|
await new Promise<void>((resolve) => {
|
||
|
|
mockMcpd.server.close(() => resolve());
|
||
|
|
});
|
||
|
|
|
||
|
|
// Now try to call - mcpd is down
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'err-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'flaky/do_thing', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeDefined();
|
||
|
|
expect(response.error?.message).toContain('mcpd proxy error');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('falls back to unfiltered response when LLM fails', async () => {
|
||
|
|
const largePayload = Array.from({ length: 60 }, (_, i) => ({
|
||
|
|
id: i,
|
||
|
|
data: 'x'.repeat(30),
|
||
|
|
}));
|
||
|
|
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-fallback', name: 'fallbackserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-fallback:tools/list', {
|
||
|
|
result: { tools: [{ name: 'get_data', description: 'Get data' }] },
|
||
|
|
}],
|
||
|
|
['srv-fallback:tools/call', {
|
||
|
|
result: { items: largePayload },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// Use a failing LLM provider
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
registry.register(createFailingLlmProvider('failing-llm'));
|
||
|
|
|
||
|
|
const processor = new LlmProcessor(registry, {
|
||
|
|
...DEFAULT_PROCESSOR_CONFIG,
|
||
|
|
enableFiltering: true,
|
||
|
|
tokenThreshold: 10, // Low threshold to trigger filtering
|
||
|
|
});
|
||
|
|
router.setLlmProcessor(processor);
|
||
|
|
|
||
|
|
// Call - LLM will fail, should fall back to original response
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'fallback-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'fallbackserver/get_data', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeUndefined();
|
||
|
|
// Should still get the original (unfiltered) response
|
||
|
|
const result = response.result as { items: Array<{ id: number; data: string }> };
|
||
|
|
expect(result.items).toHaveLength(60);
|
||
|
|
expect(result.items[0]?.data).toBe('x'.repeat(30));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns proper error for unknown tool name', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [{ id: 'srv-known', name: 'knownserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-known:tools/list', {
|
||
|
|
result: { tools: [{ name: 'real_tool', description: 'Real' }] },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// Try to call a tool that doesn't exist
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'unknown-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'knownserver/nonexistent_tool', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeDefined();
|
||
|
|
expect(response.error?.code).toBe(-32601);
|
||
|
|
expect(response.error?.message).toContain('Unknown');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns error for completely unknown server prefix', async () => {
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'no-server-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'nonexistent_server/some_tool', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeDefined();
|
||
|
|
expect(response.error?.code).toBe(-32601);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns method not found for unsupported methods', async () => {
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const response = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'bad-method',
|
||
|
|
method: 'completions/complete',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(response.error).toBeDefined();
|
||
|
|
expect(response.error?.code).toBe(-32601);
|
||
|
|
expect(response.error?.message).toContain('Method not found');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 6. Health monitoring
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Health monitoring', () => {
|
||
|
|
it('reports connected when mcpd is healthy', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
instances: [
|
||
|
|
{ name: 'slack', status: 'running' },
|
||
|
|
{ name: 'github', status: 'running' },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, mockMcpd.config.expectedToken);
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
registry.register(createMockLlmProvider('test-health', () => '{}'));
|
||
|
|
|
||
|
|
const monitor = new TieredHealthMonitor({
|
||
|
|
mcpdClient: client,
|
||
|
|
providerRegistry: registry,
|
||
|
|
mcpdUrl: mockMcpd.baseUrl,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Need to set router just so afterEach cleanup works
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const health = await monitor.checkHealth();
|
||
|
|
|
||
|
|
expect(health.mcplocal.status).toBe('healthy');
|
||
|
|
expect(health.mcplocal.llmProvider).toBe('test-health');
|
||
|
|
expect(health.mcpd.status).toBe('connected');
|
||
|
|
expect(health.mcpd.url).toBe(mockMcpd.baseUrl);
|
||
|
|
expect(health.instances).toHaveLength(2);
|
||
|
|
expect(health.instances[0]).toEqual({ name: 'slack', status: 'running' });
|
||
|
|
expect(health.instances[1]).toEqual({ name: 'github', status: 'running' });
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports disconnected when mcpd is unreachable', async () => {
|
||
|
|
// Point at a port nothing is listening on
|
||
|
|
const client = new McpdClient('http://127.0.0.1:1', 'token');
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
|
||
|
|
const monitor = new TieredHealthMonitor({
|
||
|
|
mcpdClient: client,
|
||
|
|
providerRegistry: registry,
|
||
|
|
mcpdUrl: 'http://127.0.0.1:1',
|
||
|
|
});
|
||
|
|
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const health = await monitor.checkHealth();
|
||
|
|
|
||
|
|
expect(health.mcplocal.status).toBe('healthy');
|
||
|
|
expect(health.mcpd.status).toBe('disconnected');
|
||
|
|
expect(health.instances).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports disconnected when mcpdClient is null', async () => {
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
|
||
|
|
const monitor = new TieredHealthMonitor({
|
||
|
|
mcpdClient: null,
|
||
|
|
providerRegistry: registry,
|
||
|
|
mcpdUrl: 'http://localhost:3100',
|
||
|
|
});
|
||
|
|
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const health = await monitor.checkHealth();
|
||
|
|
|
||
|
|
expect(health.mcpd.status).toBe('disconnected');
|
||
|
|
expect(health.instances).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('reports null LLM provider when none registered', async () => {
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
|
||
|
|
const monitor = new TieredHealthMonitor({
|
||
|
|
mcpdClient: null,
|
||
|
|
providerRegistry: registry,
|
||
|
|
mcpdUrl: 'http://localhost:3100',
|
||
|
|
});
|
||
|
|
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
const health = await monitor.checkHealth();
|
||
|
|
expect(health.mcplocal.llmProvider).toBeNull();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// 7. Auth token propagation
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Auth token propagation', () => {
|
||
|
|
it('sends Bearer token in all requests to mcpd', async () => {
|
||
|
|
const secretToken = 'super-secret-bearer-token-xyz';
|
||
|
|
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
expectedToken: secretToken,
|
||
|
|
servers: [{ id: 'srv-auth', name: 'authserver', transport: 'stdio' }],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-auth:tools/list', {
|
||
|
|
result: { tools: [{ name: 'protected_op', description: 'Protected operation' }] },
|
||
|
|
}],
|
||
|
|
['srv-auth:tools/call', {
|
||
|
|
result: { message: 'authorized access granted' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, secretToken);
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// Discovery calls
|
||
|
|
await refreshUpstreams(router, client);
|
||
|
|
await router.discoverTools();
|
||
|
|
|
||
|
|
// Tool call
|
||
|
|
await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'auth-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'authserver/protected_op', arguments: {} },
|
||
|
|
});
|
||
|
|
|
||
|
|
// Verify all requests carried the correct Bearer token
|
||
|
|
const requestLog = mockMcpd.config.requestLog;
|
||
|
|
expect(requestLog.length).toBeGreaterThan(0);
|
||
|
|
|
||
|
|
for (const entry of requestLog) {
|
||
|
|
expect(entry.authHeader).toBe(`Bearer ${secretToken}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
it('receives 401 when token is wrong', async () => {
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
expectedToken: 'correct-token',
|
||
|
|
servers: [{ id: 'srv-sec', name: 'secserver', transport: 'stdio' }],
|
||
|
|
});
|
||
|
|
|
||
|
|
// Client uses the wrong token
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, 'wrong-token');
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// refreshUpstreams should fail because mcpd rejects the auth
|
||
|
|
await expect(refreshUpstreams(router, client)).rejects.toThrow();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('propagates token through McpdUpstream to proxy requests', async () => {
|
||
|
|
const token = 'upstream-bearer-token';
|
||
|
|
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
expectedToken: token,
|
||
|
|
servers: [],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-direct:tools/call', {
|
||
|
|
result: { data: 'success' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, token);
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// Manually create and add an upstream (bypassing discovery)
|
||
|
|
const upstream = new McpdUpstream('srv-direct', 'directserver', client);
|
||
|
|
router.addUpstream(upstream);
|
||
|
|
|
||
|
|
// Send a direct request through the upstream
|
||
|
|
const request: JsonRpcRequest = {
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'direct-auth-1',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'test_tool', arguments: {} },
|
||
|
|
};
|
||
|
|
const response = await upstream.send(request);
|
||
|
|
|
||
|
|
expect(response.result).toEqual({ data: 'success' });
|
||
|
|
|
||
|
|
// Verify the proxy request carried the Bearer token
|
||
|
|
const proxyRequests = mockMcpd.config.requestLog.filter(
|
||
|
|
(r) => r.url === '/api/v1/mcp/proxy',
|
||
|
|
);
|
||
|
|
expect(proxyRequests.length).toBeGreaterThan(0);
|
||
|
|
for (const req of proxyRequests) {
|
||
|
|
expect(req.authHeader).toBe(`Bearer ${token}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
// Combined multi-tier scenario
|
||
|
|
// -----------------------------------------------------------------------
|
||
|
|
describe('Full multi-tier scenario', () => {
|
||
|
|
it('exercises the complete lifecycle: init -> discover -> call -> filter -> health', async () => {
|
||
|
|
const largeResult = {
|
||
|
|
records: Array.from({ length: 50 }, (_, i) => ({
|
||
|
|
id: i,
|
||
|
|
title: `Record ${String(i)}`,
|
||
|
|
body: 'x'.repeat(40),
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
const filteredResult = {
|
||
|
|
records: [{ id: 0, title: 'Record 0' }, { id: 1, title: 'Record 1' }],
|
||
|
|
};
|
||
|
|
|
||
|
|
mockMcpd = await startMockMcpd({
|
||
|
|
servers: [
|
||
|
|
{ id: 'srv-wiki', name: 'wiki', transport: 'stdio' },
|
||
|
|
{ id: 'srv-jira', name: 'jira', transport: 'stdio' },
|
||
|
|
],
|
||
|
|
proxyResponses: new Map([
|
||
|
|
['srv-wiki:tools/list', {
|
||
|
|
result: {
|
||
|
|
tools: [
|
||
|
|
{ name: 'search_pages', description: 'Search wiki pages' },
|
||
|
|
{ name: 'create_page', description: 'Create a wiki page' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
['srv-jira:tools/list', {
|
||
|
|
result: {
|
||
|
|
tools: [
|
||
|
|
{ name: 'search_issues', description: 'Search Jira issues' },
|
||
|
|
{ name: 'create_issue', description: 'Create a Jira issue' },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
}],
|
||
|
|
['srv-wiki:tools/call', {
|
||
|
|
result: largeResult,
|
||
|
|
}],
|
||
|
|
['srv-jira:tools/call', {
|
||
|
|
result: { created: true, key: 'PROJ-123' },
|
||
|
|
}],
|
||
|
|
]),
|
||
|
|
instances: [
|
||
|
|
{ name: 'wiki', status: 'running' },
|
||
|
|
{ name: 'jira', status: 'running' },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
const token = mockMcpd.config.expectedToken;
|
||
|
|
const client = new McpdClient(mockMcpd.baseUrl, token);
|
||
|
|
router = new McpRouter();
|
||
|
|
|
||
|
|
// Step 1: Initialize proxy
|
||
|
|
const initResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'lifecycle-init',
|
||
|
|
method: 'initialize',
|
||
|
|
});
|
||
|
|
expect(initResponse.error).toBeUndefined();
|
||
|
|
|
||
|
|
// Step 2: Discover servers
|
||
|
|
const registered = await refreshUpstreams(router, client);
|
||
|
|
expect(registered.sort()).toEqual(['jira', 'wiki']);
|
||
|
|
|
||
|
|
// Step 3: Discover tools
|
||
|
|
const tools = await router.discoverTools();
|
||
|
|
expect(tools).toHaveLength(4);
|
||
|
|
|
||
|
|
// Step 4: Set up LLM filtering
|
||
|
|
const registry = new ProviderRegistry();
|
||
|
|
const mockProvider = createMockLlmProvider('lifecycle-llm', () => {
|
||
|
|
return JSON.stringify(filteredResult);
|
||
|
|
});
|
||
|
|
registry.register(mockProvider);
|
||
|
|
|
||
|
|
const processor = new LlmProcessor(registry, {
|
||
|
|
...DEFAULT_PROCESSOR_CONFIG,
|
||
|
|
enableFiltering: true,
|
||
|
|
tokenThreshold: 10,
|
||
|
|
});
|
||
|
|
router.setLlmProcessor(processor);
|
||
|
|
|
||
|
|
// Step 5: Call a search tool -> should get filtered response
|
||
|
|
const searchResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'lifecycle-search',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'wiki/search_pages', arguments: { query: 'deployment' } },
|
||
|
|
});
|
||
|
|
expect(searchResponse.error).toBeUndefined();
|
||
|
|
const searchResult = searchResponse.result as { records: Array<{ id: number; title: string }> };
|
||
|
|
expect(searchResult.records).toHaveLength(2); // Filtered down
|
||
|
|
|
||
|
|
// Step 6: Call a create tool -> should bypass LLM
|
||
|
|
const createResponse = await router.route({
|
||
|
|
jsonrpc: '2.0',
|
||
|
|
id: 'lifecycle-create',
|
||
|
|
method: 'tools/call',
|
||
|
|
params: { name: 'jira/create_issue', arguments: { summary: 'New bug' } },
|
||
|
|
});
|
||
|
|
expect(createResponse.error).toBeUndefined();
|
||
|
|
expect((createResponse.result as Record<string, unknown>)['key']).toBe('PROJ-123');
|
||
|
|
|
||
|
|
// Step 7: Check health
|
||
|
|
const monitor = new TieredHealthMonitor({
|
||
|
|
mcpdClient: client,
|
||
|
|
providerRegistry: registry,
|
||
|
|
mcpdUrl: mockMcpd.baseUrl,
|
||
|
|
});
|
||
|
|
const health = await monitor.checkHealth();
|
||
|
|
expect(health.mcplocal.status).toBe('healthy');
|
||
|
|
expect(health.mcplocal.llmProvider).toBe('lifecycle-llm');
|
||
|
|
expect(health.mcpd.status).toBe('connected');
|
||
|
|
expect(health.instances).toHaveLength(2);
|
||
|
|
|
||
|
|
// Step 8: Verify all requests used auth
|
||
|
|
for (const entry of mockMcpd.config.requestLog) {
|
||
|
|
expect(entry.authHeader).toBe(`Bearer ${token}`);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|