chore: import upstream snapshot with attribution
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:24:08 +08:00
commit 0d3cb498a3
5438 changed files with 1316560 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mcpCommand } from '../../../src/commands/mcp/index';
import { startStdioMcpServer } from '../../../src/commands/mcp/server';
import logger from '../../../src/logger';
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../src/telemetry', () => ({
default: {
record: vi.fn(),
},
}));
vi.mock('../../../src/commands/mcp/server', () => ({
startHttpMcpServer: vi.fn(),
startStdioMcpServer: vi.fn(),
createMcpServer: vi.fn(),
}));
describe('mcp command', () => {
let program: Command;
beforeEach(() => {
vi.clearAllMocks();
process.exitCode = undefined;
program = new Command();
mcpCommand(program);
});
afterEach(() => {
vi.resetAllMocks();
});
describe('validation', () => {
it('should set exitCode=1 for invalid transport type', async () => {
const mcpCmd = program.commands.find((cmd) => cmd.name() === 'mcp');
expect(mcpCmd).toBeDefined();
await mcpCmd!.parseAsync(['node', 'test', '--transport', 'invalid']);
expect(logger.error).toHaveBeenCalledWith(
'Invalid transport type: invalid. Must be "http" or "stdio".',
);
expect(process.exitCode).toBe(1);
});
it('should set exitCode=1 for invalid port number', async () => {
const mcpCmd = program.commands.find((cmd) => cmd.name() === 'mcp');
expect(mcpCmd).toBeDefined();
await mcpCmd!.parseAsync(['node', 'test', '--port', 'not-a-number']);
expect(logger.error).toHaveBeenCalledWith('Invalid port number: not-a-number');
expect(process.exitCode).toBe(1);
});
});
describe('startup failures', () => {
it('logs dependency errors and sets exitCode=1', async () => {
vi.mocked(startStdioMcpServer).mockRejectedValueOnce(
new Error('The @modelcontextprotocol/sdk package is required for MCP server support.'),
);
const mcpCmd = program.commands.find((cmd) => cmd.name() === 'mcp');
expect(mcpCmd).toBeDefined();
await mcpCmd!.parseAsync(['node', 'test', '--transport', 'stdio']);
expect(logger.error).toHaveBeenCalledWith(
'Failed to start MCP server: The @modelcontextprotocol/sdk package is required for MCP server support.',
);
expect(process.exitCode).toBe(1);
});
});
});
+218
View File
@@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest';
import {
AuthenticationError,
ConfigurationError,
FileOperationError,
isMcpError,
NotFoundError,
ProviderError,
RateLimitError,
ServiceUnavailableError,
SharingError,
TimeoutError,
toMcpError,
ValidationError,
} from '../../../../src/commands/mcp/lib/errors';
describe('MCP Errors', () => {
describe('ValidationError', () => {
it('should create validation error with message', () => {
const error = new ValidationError('Invalid input');
expect(error.message).toBe('Invalid input');
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.statusCode).toBe(400);
});
it('should include details when provided', () => {
const details = { field: 'email', value: 'invalid' };
const error = new ValidationError('Invalid email', details);
expect(error.details).toEqual(details);
});
it('should serialize to JSON correctly', () => {
const error = new ValidationError('Test error', { foo: 'bar' });
expect(error.toJSON()).toEqual({
code: 'VALIDATION_ERROR',
message: 'Test error',
details: { foo: 'bar' },
});
});
});
describe('NotFoundError', () => {
it('should create error with resource and id', () => {
const error = new NotFoundError('Evaluation', 'eval_123');
expect(error.message).toBe('Evaluation with ID "eval_123" not found');
expect(error.details).toEqual({ resource: 'Evaluation', id: 'eval_123' });
});
it('should create error with resource only', () => {
const error = new NotFoundError('Configuration');
expect(error.message).toBe('Configuration not found');
expect(error.details).toEqual({ resource: 'Configuration', id: undefined });
});
});
describe('ConfigurationError', () => {
it('should create configuration error', () => {
const error = new ConfigurationError('Invalid config', '/path/to/config.yaml');
expect(error.message).toBe('Invalid config');
expect(error.details).toEqual({ configPath: '/path/to/config.yaml' });
expect(error.statusCode).toBe(400);
});
});
describe('ProviderError', () => {
it('should create provider error with details', () => {
const error = new ProviderError('openai:gpt-4', 'API key invalid', { endpoint: '/v1/chat' });
expect(error.message).toBe('Provider "openai:gpt-4" error: API key invalid');
expect(error.details).toEqual({ providerId: 'openai:gpt-4', endpoint: '/v1/chat' });
expect(error.statusCode).toBe(500);
});
});
describe('TimeoutError', () => {
it('should create timeout error', () => {
const error = new TimeoutError('API call', 30000);
expect(error.message).toBe('Operation "API call" timed out after 30000ms');
expect(error.details).toEqual({ operation: 'API call', timeoutMs: 30000 });
expect(error.statusCode).toBe(408);
});
});
describe('ServiceUnavailableError', () => {
it('should create service unavailable error', () => {
const error = new ServiceUnavailableError('Database', { reason: 'Connection failed' });
expect(error.message).toBe('Service "Database" is unavailable');
expect(error.details).toEqual({ service: 'Database', reason: 'Connection failed' });
expect(error.statusCode).toBe(503);
});
});
describe('AuthenticationError', () => {
it('should create authentication error with default message', () => {
const error = new AuthenticationError();
expect(error.message).toBe('Authentication failed');
expect(error.statusCode).toBe(401);
});
it('should create authentication error with custom message', () => {
const error = new AuthenticationError('Invalid API key');
expect(error.message).toBe('Invalid API key');
});
});
describe('SharingError', () => {
it('should create sharing error', () => {
const error = new SharingError('Sharing service down', { evalId: 'eval_123' });
expect(error.message).toBe('Sharing service down');
expect(error.details).toEqual({ evalId: 'eval_123' });
expect(error.statusCode).toBe(503);
});
});
describe('RateLimitError', () => {
it('should create rate limit error without retry after', () => {
const error = new RateLimitError('OpenAI API');
expect(error.message).toBe('Rate limit exceeded for OpenAI API');
expect(error.statusCode).toBe(429);
});
it('should create rate limit error with retry after', () => {
const error = new RateLimitError('OpenAI API', 60, { requests: 100 });
expect(error.message).toBe('Rate limit exceeded for OpenAI API. Retry after 60 seconds.');
expect(error.details).toEqual({ service: 'OpenAI API', retryAfter: 60, requests: 100 });
});
});
describe('FileOperationError', () => {
it('should create file operation error', () => {
const originalError = new Error('Permission denied');
const error = new FileOperationError('write', '/path/to/file.txt', originalError);
expect(error.message).toBe('Failed to write file: /path/to/file.txt');
expect(error.details).toEqual({
operation: 'write',
filePath: '/path/to/file.txt',
originalError: 'Permission denied',
});
});
});
describe('toMcpError', () => {
it('should return McpError as-is', () => {
const error = new ValidationError('Test');
expect(toMcpError(error)).toBe(error);
});
it('should convert rate limit errors', () => {
const error = new Error('Rate limit exceeded');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(RateLimitError);
});
it('should convert not found errors', () => {
const error = new Error('File not found');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(NotFoundError);
});
it('should convert ENOENT errors', () => {
const error = new Error('ENOENT: no such file or directory');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(NotFoundError);
});
it('should convert timeout errors', () => {
const error = new Error('Request timed out');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(TimeoutError);
});
it('should convert authentication errors', () => {
const error = new Error('Unauthorized access');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(AuthenticationError);
});
it('should convert configuration errors', () => {
const error = new Error('Invalid configuration');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(ConfigurationError);
});
it('should convert generic errors to ValidationError', () => {
const error = new Error('Some other error');
const mcpError = toMcpError(error);
expect(mcpError).toBeInstanceOf(ValidationError);
expect(mcpError.message).toBe('Some other error');
});
it('should handle non-Error objects', () => {
const mcpError = toMcpError('string error');
expect(mcpError).toBeInstanceOf(ValidationError);
expect(mcpError.message).toBe('Unknown error occurred');
expect(mcpError.details).toEqual({ originalError: 'string error' });
});
it('should use custom default message', () => {
const mcpError = toMcpError(null, 'Custom error message');
expect(mcpError.message).toBe('Custom error message');
});
});
describe('isMcpError', () => {
it('should return true for McpError instances', () => {
expect(isMcpError(new ValidationError('Test'))).toBe(true);
expect(isMcpError(new NotFoundError('Resource'))).toBe(true);
expect(isMcpError(new RateLimitError('Service'))).toBe(true);
});
it('should return false for non-McpError instances', () => {
expect(isMcpError(new Error('Test'))).toBe(false);
expect(isMcpError('string')).toBe(false);
expect(isMcpError(null)).toBe(false);
expect(isMcpError(undefined)).toBe(false);
expect(isMcpError({})).toBe(false);
});
});
});
+256
View File
@@ -0,0 +1,256 @@
import { describe, expect, it } from 'vitest';
import {
BatchProcessor,
EvaluationCache,
paginate,
streamProcess,
} from '../../../../src/commands/mcp/lib/performance';
import type { EvalSummary } from '../../../../src/types';
describe('MCP Performance', () => {
describe('EvaluationCache', () => {
const createMockEvalSummary = (id: string): EvalSummary => ({
evalId: id,
datasetId: null,
createdAt: Date.now(),
description: `Test evaluation ${id}`,
numTests: 10,
isRedteam: false,
passRate: 0.9,
label: `Eval ${id}`,
providers: [{ id: 'provider1', label: 'Provider 1' }],
});
it('should store and retrieve values', () => {
const cache = new EvaluationCache();
const mockEvalSummaries = [createMockEvalSummary('eval1')];
cache.set('key1', mockEvalSummaries);
expect(cache.get('key1')).toEqual(mockEvalSummaries);
});
it('should return undefined for missing keys', () => {
const cache = new EvaluationCache();
expect(cache.get('nonexistent')).toBeUndefined();
});
it('should check if key exists', () => {
const cache = new EvaluationCache();
const mockEvalSummaries = [createMockEvalSummary('eval1')];
cache.set('exists', mockEvalSummaries);
expect(cache.has('exists')).toBe(true);
expect(cache.has('missing')).toBe(false);
});
it('should clear all entries', () => {
const cache = new EvaluationCache();
const mockEvalSummaries1 = [createMockEvalSummary('eval1')];
const mockEvalSummaries2 = [createMockEvalSummary('eval2')];
cache.set('key1', mockEvalSummaries1);
cache.set('key2', mockEvalSummaries2);
cache.clear();
expect(cache.has('key1')).toBe(false);
expect(cache.has('key2')).toBe(false);
});
it('should return stats', () => {
const cache = new EvaluationCache();
const mockEvalSummaries = [createMockEvalSummary('eval1')];
cache.set('key1', mockEvalSummaries);
const stats = cache.getStats();
expect(stats.size).toBe(1);
});
});
describe('paginate', () => {
const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
it('should return first page by default', () => {
const result = paginate(items);
expect(result.data).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
expect(result.pagination.page).toBe(1);
expect(result.pagination.totalItems).toBe(10);
});
it('should paginate with custom page size', () => {
const result = paginate(items, { pageSize: 3 });
expect(result.data).toEqual([1, 2, 3]);
expect(result.pagination.totalPages).toBe(4);
expect(result.pagination.hasNextPage).toBe(true);
expect(result.pagination.hasPreviousPage).toBe(false);
});
it('should return correct page', () => {
const result = paginate(items, { page: 2, pageSize: 3 });
expect(result.data).toEqual([4, 5, 6]);
expect(result.pagination.hasPreviousPage).toBe(true);
expect(result.pagination.hasNextPage).toBe(true);
});
it('should handle last page', () => {
const result = paginate(items, { page: 4, pageSize: 3 });
expect(result.data).toEqual([10]);
expect(result.pagination.hasNextPage).toBe(false);
expect(result.pagination.hasPreviousPage).toBe(true);
});
it('should constrain page size to max', () => {
const result = paginate(items, { pageSize: 200, maxPageSize: 5 });
expect(result.pagination.pageSize).toBe(5);
});
it('should handle empty arrays', () => {
const result = paginate([]);
expect(result.data).toEqual([]);
expect(result.pagination.totalItems).toBe(0);
expect(result.pagination.totalPages).toBe(0);
});
});
describe('streamProcess', () => {
it('should process all items', async () => {
const items = [1, 2, 3, 4, 5];
const processor = async (x: number) => x * 2;
const results: number[] = [];
for await (const result of streamProcess(items, processor, 2)) {
results.push(result);
}
expect(results.sort((a, b) => a - b)).toEqual([2, 4, 6, 8, 10]);
});
it('should respect concurrency limit', async () => {
let concurrent = 0;
let maxConcurrent = 0;
// Hold each in-flight call until the SUT has filled the concurrency
// window, then release a wave together. This makes maxConcurrent
// observation independent of wall-clock pacing.
const limit = 2;
let releaseWave: (() => void) | undefined;
let waveBarrier = new Promise<void>((resolve) => {
releaseWave = resolve;
});
const items = [1, 2, 3, 4, 5, 6];
const processor = async (x: number) => {
concurrent++;
maxConcurrent = Math.max(maxConcurrent, concurrent);
if (concurrent >= limit) {
releaseWave?.();
}
let resolveFallback!: () => void;
const fallback = new Promise<void>((r) => {
resolveFallback = r;
});
const fallbackHandle = setTimeout(() => resolveFallback(), 100);
try {
await Promise.race([waveBarrier, fallback]);
} finally {
clearTimeout(fallbackHandle);
}
concurrent--;
if (concurrent === 0) {
waveBarrier = new Promise<void>((resolve) => {
releaseWave = resolve;
});
}
return x;
};
const results: number[] = [];
for await (const result of streamProcess(items, processor, 2)) {
results.push(result);
}
expect(maxConcurrent).toBeLessThanOrEqual(2);
expect(results.length).toBe(6);
});
it('should yield results as they complete', async () => {
const items = [1, 2, 3];
const processor = async (x: number) => {
await Promise.resolve();
return x;
};
const results: number[] = [];
for await (const result of streamProcess(items, processor, 3)) {
results.push(result);
}
// All items should be processed
expect(results.sort((a, b) => a - b)).toEqual([1, 2, 3]);
});
it('should handle empty input', async () => {
const results: number[] = [];
for await (const result of streamProcess([], async (x: number) => x)) {
results.push(result);
}
expect(results).toEqual([]);
});
it('should handle single item', async () => {
const results: number[] = [];
for await (const result of streamProcess([42], async (x) => x * 2)) {
results.push(result);
}
expect(results).toEqual([84]);
});
it('should properly track which promise completed', async () => {
// Verifies the fix for a bug where the wrong promise was removed when
// items completed in reverse arrival order. Use microtask-staggered
// resolution so deterministic completion order still differs from
// arrival order, without wall-clock pacing.
const items = [1, 2, 3, 4, 5];
const processor = async (x: number) => {
// x=5 yields 1 microtask, x=1 yields 5 microtasks → reverse order.
for (let i = 0; i < 6 - x; i++) {
await Promise.resolve();
}
return x;
};
const results: number[] = [];
for await (const result of streamProcess(items, processor, 3)) {
results.push(result);
}
// All items should be yielded exactly once
expect(results.length).toBe(5);
expect(results.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
});
});
describe('BatchProcessor', () => {
it('should process items in batches', async () => {
const processedBatches: number[][] = [];
const processor = async (batch: number[]) => {
processedBatches.push([...batch]);
return batch.map((x) => x * 2);
};
const batchProcessor = new BatchProcessor(processor, 2, 10);
const results = await Promise.all([
batchProcessor.add(1),
batchProcessor.add(2),
batchProcessor.add(3),
]);
// Results should be correct regardless of batching
expect(results.sort((a, b) => a - b)).toEqual([2, 4, 6]);
});
it('should handle single item', async () => {
const processor = async (batch: number[]) => batch.map((x) => x * 2);
const batchProcessor = new BatchProcessor(processor, 10, 5);
const result = await batchProcessor.add(5);
expect(result).toBe(10);
});
});
});
+138
View File
@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import { ConfigurationError } from '../../../../src/commands/mcp/lib/errors';
import { validateFilePath, validateProviderId } from '../../../../src/commands/mcp/lib/security';
import { escapeRegExp } from '../../../../src/util/text';
describe('MCP Security', () => {
describe('validateFilePath', () => {
it('should allow simple relative paths', () => {
expect(() => validateFilePath('output.yaml')).not.toThrow();
expect(() => validateFilePath('data/results.json')).not.toThrow();
expect(() => validateFilePath('my-file.txt')).not.toThrow();
});
it('should reject paths containing ".."', () => {
expect(() => validateFilePath('../etc/passwd')).toThrow(ConfigurationError);
expect(() => validateFilePath('foo/../bar')).toThrow(ConfigurationError);
expect(() => validateFilePath('/tmp/../etc/passwd')).toThrow(ConfigurationError);
});
it('should reject paths containing "~"', () => {
expect(() => validateFilePath('~/.ssh/id_rsa')).toThrow(ConfigurationError);
expect(() => validateFilePath('~/Documents/file.txt')).toThrow(ConfigurationError);
});
it.skipIf(process.platform === 'win32')('should reject paths to system directories', () => {
// Unix paths are only recognized as absolute on Unix systems
expect(() => validateFilePath('/etc/passwd')).toThrow(ConfigurationError);
expect(() => validateFilePath('/sys/kernel')).toThrow(ConfigurationError);
expect(() => validateFilePath('/proc/self/environ')).toThrow(ConfigurationError);
expect(() => validateFilePath('/dev/null')).toThrow(ConfigurationError);
expect(() => validateFilePath('/var/run/docker.sock')).toThrow(ConfigurationError);
});
it.skipIf(process.platform !== 'win32')('should reject Windows system directories', () => {
// Windows paths are only recognized as absolute on Windows
expect(() => validateFilePath('C:\\Windows\\System32\\config')).toThrow(ConfigurationError);
expect(() => validateFilePath('C:\\Program Files\\app')).toThrow(ConfigurationError);
expect(() => validateFilePath('C:\\ProgramData\\secret')).toThrow(ConfigurationError);
});
it.skipIf(process.platform === 'win32')(
'should allow absolute paths to non-system directories',
() => {
// Unix paths are only recognized as absolute on Unix systems
expect(() => validateFilePath('/tmp/output.yaml')).not.toThrow();
expect(() => validateFilePath('/home/user/data.json')).not.toThrow();
expect(() => validateFilePath('/Users/test/file.txt')).not.toThrow();
},
);
describe('with basePath', () => {
it('should allow paths within the base directory', () => {
expect(() => validateFilePath('output.yaml', '/tmp/workdir')).not.toThrow();
expect(() => validateFilePath('subdir/file.txt', '/tmp/workdir')).not.toThrow();
});
it('should reject paths that escape the base directory', () => {
// Note: ".." is caught by the pre-normalization check
expect(() => validateFilePath('../escape.txt', '/tmp/workdir')).toThrow(ConfigurationError);
});
});
it('should include the original path in the error details', () => {
try {
validateFilePath('../etc/passwd');
expect.fail('Expected error to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(ConfigurationError);
expect((error as ConfigurationError).details).toEqual({ configPath: '../etc/passwd' });
}
});
});
describe('escapeRegExp', () => {
it('should escape special regex characters', () => {
expect(escapeRegExp('hello.world')).toBe('hello\\.world');
expect(escapeRegExp('foo*bar')).toBe('foo\\*bar');
expect(escapeRegExp('a+b')).toBe('a\\+b');
expect(escapeRegExp('test?')).toBe('test\\?');
expect(escapeRegExp('^start')).toBe('\\^start');
expect(escapeRegExp('end$')).toBe('end\\$');
});
it('should escape brackets and braces', () => {
expect(escapeRegExp('[abc]')).toBe('\\[abc\\]');
expect(escapeRegExp('{1,3}')).toBe('\\{1,3\\}');
expect(escapeRegExp('(group)')).toBe('\\(group\\)');
});
it('should escape pipe and backslash', () => {
expect(escapeRegExp('a|b')).toBe('a\\|b');
expect(escapeRegExp('path\\to\\file')).toBe('path\\\\to\\\\file');
});
it('should return strings without special chars unchanged', () => {
expect(escapeRegExp('hello')).toBe('hello');
expect(escapeRegExp('openai:gpt-4')).toBe('openai:gpt-4');
expect(escapeRegExp('simple_test')).toBe('simple_test');
});
it('should handle empty strings', () => {
expect(escapeRegExp('')).toBe('');
});
it('should produce strings safe for regex construction', () => {
const userInput = 'openai:gpt-4.0';
const escaped = escapeRegExp(userInput);
const regex = new RegExp(escaped);
expect(regex.test('openai:gpt-4.0')).toBe(true);
expect(regex.test('openai:gpt-4X0')).toBe(false); // "." should not match any char
});
});
describe('validateProviderId', () => {
it('should accept valid provider:model format', () => {
expect(() => validateProviderId('openai:gpt-4')).not.toThrow();
expect(() => validateProviderId('anthropic:claude-3')).not.toThrow();
expect(() => validateProviderId('azure:gpt-4o')).not.toThrow();
});
it('should accept file path providers', () => {
expect(() => validateProviderId('providers/custom.js')).not.toThrow();
expect(() => validateProviderId('my-provider.ts')).not.toThrow();
expect(() => validateProviderId('script.py')).not.toThrow();
expect(() => validateProviderId('module.mjs')).not.toThrow();
});
it('should accept HTTP providers', () => {
expect(() => validateProviderId('http://localhost:8080/api')).not.toThrow();
expect(() => validateProviderId('https://api.example.com/v1')).not.toThrow();
});
it('should reject invalid formats', () => {
expect(() => validateProviderId('invalid')).toThrow(ConfigurationError);
expect(() => validateProviderId('')).toThrow(ConfigurationError);
});
});
});
+149
View File
@@ -0,0 +1,149 @@
import { beforeEach, describe, expect, it } from 'vitest';
import {
initializeToolRegistry,
TOOL_DEFINITIONS,
toolRegistry,
} from '../../../../src/commands/mcp/lib/toolRegistry';
describe('ToolRegistry', () => {
beforeEach(() => {
// Re-initialize the registry for each test
initializeToolRegistry();
});
describe('TOOL_DEFINITIONS', () => {
it('should define all 14 MCP tools', () => {
expect(TOOL_DEFINITIONS.length).toBe(14);
});
it('should have all tools with required metadata', () => {
for (const tool of TOOL_DEFINITIONS) {
expect(tool.name).toBeDefined();
expect(tool.name.length).toBeGreaterThan(0);
expect(tool.description).toBeDefined();
expect(tool.description.length).toBeGreaterThan(0);
expect(tool.parameters).toBeDefined();
expect(tool.annotations).toBeDefined();
expect(tool.category).toMatch(/^(evaluation|generation|redteam|configuration|debugging)$/);
}
});
it('should have correct tool names', () => {
const expectedToolNames = [
'list_evaluations',
'get_evaluation_details',
'run_evaluation',
'share_evaluation',
'validate_promptfoo_config',
'test_provider',
'run_assertion',
'generate_dataset',
'generate_test_cases',
'compare_providers',
'redteam_generate',
'redteam_run',
'list_logs',
'read_logs',
];
const actualToolNames = TOOL_DEFINITIONS.map((t) => t.name);
expect(actualToolNames).toEqual(expect.arrayContaining(expectedToolNames));
expect(actualToolNames.length).toBe(expectedToolNames.length);
});
it('should have read-only hints for read-only tools', () => {
const readOnlyTools = [
'list_evaluations',
'get_evaluation_details',
'validate_promptfoo_config',
'test_provider',
'run_assertion',
'compare_providers',
'list_logs',
'read_logs',
];
for (const toolName of readOnlyTools) {
const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName);
expect(tool?.annotations.readOnlyHint).toBe(true);
}
});
it('should have long-running hints for long-running tools', () => {
const longRunningTools = [
'run_evaluation',
'generate_dataset',
'generate_test_cases',
'compare_providers',
'redteam_generate',
'redteam_run',
];
for (const toolName of longRunningTools) {
const tool = TOOL_DEFINITIONS.find((t) => t.name === toolName);
expect(tool?.annotations.longRunningHint).toBe(true);
}
});
});
describe('registry operations', () => {
it('should retrieve all registered tools', () => {
const tools = toolRegistry.getAll();
expect(tools.length).toBe(14);
});
it('should retrieve a tool by name', () => {
const tool = toolRegistry.get('list_evaluations');
expect(tool).toBeDefined();
expect(tool?.name).toBe('list_evaluations');
expect(tool?.category).toBe('evaluation');
});
it('should return undefined for non-existent tool', () => {
const tool = toolRegistry.get('non_existent_tool');
expect(tool).toBeUndefined();
});
it('should get tools by category', () => {
const evaluationTools = toolRegistry.getByCategory('evaluation');
expect(evaluationTools.length).toBe(4);
expect(evaluationTools.every((t) => t.category === 'evaluation')).toBe(true);
const generationTools = toolRegistry.getByCategory('generation');
expect(generationTools.length).toBe(3);
const redteamTools = toolRegistry.getByCategory('redteam');
expect(redteamTools.length).toBe(2);
const configTools = toolRegistry.getByCategory('configuration');
expect(configTools.length).toBe(3);
const debuggingTools = toolRegistry.getByCategory('debugging');
expect(debuggingTools.length).toBe(2);
expect(debuggingTools.every((t) => t.category === 'debugging')).toBe(true);
});
});
describe('generateDocs', () => {
it('should generate documentation object', () => {
const docs = toolRegistry.generateDocs();
expect(docs.totalTools).toBe(14);
expect(docs.version).toBe('1.0.0');
expect(docs.lastUpdated).toBeDefined();
expect(docs.tools.length).toBe(14);
});
it('should include all tool fields in docs', () => {
const docs = toolRegistry.generateDocs();
for (const tool of docs.tools) {
expect(tool.name).toBeDefined();
expect(tool.description).toBeDefined();
expect(tool.parameters).toBeDefined();
expect(tool.category).toBeDefined();
expect(tool.annotations).toBeDefined();
}
});
});
});
+467
View File
@@ -0,0 +1,467 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
assertNotNull,
createToolResponse,
debounce,
filterNonNull,
formatDuration,
getProperty,
hasKey,
retry,
safeStringify,
truncateText,
withTimeout,
} from '../../../../src/commands/mcp/lib/utils';
describe('MCP Utility Functions', () => {
describe('createToolResponse', () => {
it('should create successful tool response with data', () => {
const response = createToolResponse('test_tool', true, { result: 'success' });
expect(response.isError).toBe(false);
expect(response.content).toHaveLength(1);
expect(response.content[0].type).toBe('text');
const parsedContent = JSON.parse(response.content[0].text);
expect(parsedContent.tool).toBe('test_tool');
expect(parsedContent.success).toBe(true);
expect(parsedContent.data).toEqual({ result: 'success' });
expect(parsedContent.timestamp).toBeDefined();
expect(parsedContent.error).toBeUndefined();
});
it('should create error tool response with error message', () => {
const response = createToolResponse('test_tool', false, undefined, 'Something went wrong');
expect(response.isError).toBe(true);
expect(response.content).toHaveLength(1);
const parsedContent = JSON.parse(response.content[0].text);
expect(parsedContent.tool).toBe('test_tool');
expect(parsedContent.success).toBe(false);
expect(parsedContent.error).toBe('Something went wrong');
expect(parsedContent.data).toBeUndefined();
});
it('should create response without data or error', () => {
const response = createToolResponse('test_tool', true);
const parsedContent = JSON.parse(response.content[0].text);
expect(parsedContent.tool).toBe('test_tool');
expect(parsedContent.success).toBe(true);
expect(parsedContent.data).toBeUndefined();
expect(parsedContent.error).toBeUndefined();
});
it('should include timestamp in ISO format', () => {
const response = createToolResponse('test_tool', true);
const parsedContent = JSON.parse(response.content[0].text);
expect(parsedContent.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
});
});
describe('withTimeout', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should resolve when promise resolves before timeout', async () => {
const promise = Promise.resolve('success');
const result = await withTimeout(promise, 1000, 'Timed out');
expect(result).toBe('success');
});
it('should reject when promise times out', async () => {
const promise = new Promise((resolve) => setTimeout(() => resolve('late'), 100));
const result = withTimeout(promise, 50, 'Operation timed out');
const expectation = expect(result).rejects.toThrow('Operation timed out');
await vi.runAllTimersAsync();
await expectation;
});
it('should reject with original error if promise rejects', async () => {
const promise = Promise.reject(new Error('Original error'));
await expect(withTimeout(promise, 1000, 'Timed out')).rejects.toThrow('Original error');
});
});
describe('safeStringify', () => {
it('should stringify normal objects', () => {
const obj = { name: 'test', value: 123 };
const result = safeStringify(obj);
expect(result).toBe('{"name":"test","value":123}');
});
it('should handle BigInt values', () => {
const obj = { bigNumber: BigInt(123456789012345) };
const result = safeStringify(obj);
expect(result).toBe('{"bigNumber":"123456789012345"}');
});
it('should handle function values', () => {
const obj = { func: () => 'test' };
const result = safeStringify(obj);
expect(result).toBe('{"func":"[Function]"}');
});
it('should handle undefined values', () => {
const obj = { undef: undefined };
const result = safeStringify(obj);
expect(result).toBe('{"undef":"[Undefined]"}');
});
it('should use custom replacer when provided', () => {
const obj = { secret: 'password', public: 'data' };
const result = safeStringify(obj, 2, (key, value) =>
key === 'secret' ? '[REDACTED]' : value,
);
expect(result).toContain('[REDACTED]');
expect(result).toContain('data');
});
it('should format with spacing when provided', () => {
const obj = { a: 1, b: 2 };
const result = safeStringify(obj, 2);
expect(result).toContain('\n');
expect(result).toContain(' ');
});
});
describe('hasKey', () => {
it('should return true when key exists', () => {
const obj = { name: 'test', value: 123 };
const result = hasKey(obj, 'name');
expect(result).toBe(true);
});
it('should return false when key does not exist', () => {
const obj = { name: 'test' };
const result = hasKey(obj, 'missing');
expect(result).toBe(false);
});
it('should work with inherited properties', () => {
const obj = Object.create({ inherited: 'value' });
obj.own = 'value';
expect(hasKey(obj, 'own')).toBe(true);
expect(hasKey(obj, 'inherited')).toBe(true);
});
});
describe('getProperty', () => {
it('should return property value when it exists', () => {
const obj = { name: 'test', value: 123 };
const result = getProperty(obj, 'name', 'default');
expect(result).toBe('test');
});
it('should return default value when property is undefined', () => {
const obj = { name: 'test' };
const result = getProperty(obj, 'missing', 'default');
expect(result).toBe('default');
});
it('should return property value when it is null', () => {
const obj = { value: null };
const result = getProperty(obj, 'value', 'default');
expect(result).toBeNull();
});
it('should return property value when it is falsy but not undefined', () => {
const obj = { value: 0, empty: '', bool: false };
expect(getProperty(obj, 'value', 'default')).toBe(0);
expect(getProperty(obj, 'empty', 'default')).toBe('');
expect(getProperty(obj, 'bool', 'default')).toBe(false);
});
});
describe('assertNotNull', () => {
it('should return value when it is not null or undefined', () => {
expect(assertNotNull('test')).toBe('test');
expect(assertNotNull(0)).toBe(0);
expect(assertNotNull(false)).toBe(false);
expect(assertNotNull('')).toBe('');
});
it('should throw error when value is null', () => {
expect(() => assertNotNull(null)).toThrow('Value cannot be null or undefined');
});
it('should throw error when value is undefined', () => {
expect(() => assertNotNull(undefined)).toThrow('Value cannot be null or undefined');
});
it('should throw custom error message', () => {
expect(() => assertNotNull(null, 'Custom error message')).toThrow('Custom error message');
});
});
describe('filterNonNull', () => {
it('should filter out null and undefined values', () => {
const array = ['a', null, 'b', undefined, 'c', null];
const result = filterNonNull(array);
expect(result).toEqual(['a', 'b', 'c']);
});
it('should preserve falsy but non-null values', () => {
const array = ['', 0, false, null, undefined, 'test'];
const result = filterNonNull(array);
expect(result).toEqual(['', 0, false, 'test']);
});
it('should return empty array for all null/undefined', () => {
const array = [null, undefined, null];
const result = filterNonNull(array);
expect(result).toEqual([]);
});
it('should return same array if no null/undefined values', () => {
const array = ['a', 'b', 'c'];
const result = filterNonNull(array);
expect(result).toEqual(['a', 'b', 'c']);
});
});
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should debounce function calls', () => {
const mockFn = vi.fn();
const debouncedFn = debounce(mockFn, 100);
debouncedFn('arg1');
debouncedFn('arg2');
debouncedFn('arg3');
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg3');
});
it('should reset timer on subsequent calls', () => {
const mockFn = vi.fn();
const debouncedFn = debounce(mockFn, 100);
debouncedFn('arg1');
vi.advanceTimersByTime(50);
debouncedFn('arg2');
vi.advanceTimersByTime(50);
expect(mockFn).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(mockFn).toHaveBeenCalledTimes(1);
expect(mockFn).toHaveBeenCalledWith('arg2');
});
});
describe('retry', () => {
it('should succeed on first attempt', async () => {
const operation = vi.fn().mockResolvedValue('success');
const result = await retry(operation);
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(1);
});
it('should retry on failure and eventually succeed', async () => {
const operation = vi
.fn()
.mockRejectedValueOnce(new Error('fail1'))
.mockRejectedValueOnce(new Error('fail2'))
.mockResolvedValue('success');
const result = await retry(operation, { maxAttempts: 3, baseDelay: 10 });
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(3);
});
it('should throw last error after max attempts', async () => {
const operation = vi.fn().mockRejectedValue(new Error('persistent failure'));
await expect(retry(operation, { maxAttempts: 2, baseDelay: 10 })).rejects.toThrow(
'persistent failure',
);
expect(operation).toHaveBeenCalledTimes(2);
});
it('should use exponential backoff', async () => {
const operation = vi
.fn()
.mockRejectedValueOnce(new Error('fail1'))
.mockRejectedValueOnce(new Error('fail2'))
.mockResolvedValue('success');
const startTime = Date.now();
// Mock Date.now to control timing
const mockDateNow = vi.spyOn(Date, 'now');
let timeOffset = 0;
mockDateNow.mockImplementation(function () {
return startTime + timeOffset;
});
// Mock setTimeout to simulate delays
const mockSetTimeout = vi.spyOn(global, 'setTimeout');
mockSetTimeout.mockImplementation(function (callback, delay) {
timeOffset += delay ?? 0;
callback();
return {} as any;
});
await retry(operation, { maxAttempts: 3, baseDelay: 100, backoffFactor: 2 });
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 100);
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 200);
mockDateNow.mockRestore();
mockSetTimeout.mockRestore();
});
it('should respect max delay', async () => {
const operation = vi
.fn()
.mockRejectedValueOnce(new Error('fail1'))
.mockResolvedValue('success');
const mockSetTimeout = vi.spyOn(global, 'setTimeout');
mockSetTimeout.mockImplementation(function (callback) {
callback();
return {} as any;
});
await retry(operation, {
maxAttempts: 2,
baseDelay: 1000,
backoffFactor: 10,
maxDelay: 500,
});
expect(mockSetTimeout).toHaveBeenCalledWith(expect.any(Function), 500);
mockSetTimeout.mockRestore();
});
});
describe('formatDuration', () => {
it('should format milliseconds', () => {
expect(formatDuration(500)).toBe('500ms');
expect(formatDuration(999)).toBe('999ms');
});
it('should format seconds', () => {
expect(formatDuration(1000)).toBe('1s');
expect(formatDuration(30000)).toBe('30s');
expect(formatDuration(59000)).toBe('59s');
});
it('should format minutes and seconds', () => {
expect(formatDuration(60000)).toBe('1m');
expect(formatDuration(90000)).toBe('1m 30s');
expect(formatDuration(3599000)).toBe('59m 59s');
});
it('should format hours and minutes', () => {
expect(formatDuration(3600000)).toBe('1h');
expect(formatDuration(3660000)).toBe('1h 1m');
expect(formatDuration(7200000)).toBe('2h');
});
it('should handle zero duration', () => {
expect(formatDuration(0)).toBe('0ms');
});
});
describe('truncateText', () => {
it('should return original text if shorter than max length', () => {
const text = 'Short text';
const result = truncateText(text, 20);
expect(result).toBe('Short text');
});
it('should return original text if equal to max length', () => {
const text = 'Exact length';
const result = truncateText(text, 12);
expect(result).toBe('Exact length');
});
it('should truncate and add ellipsis if longer than max length', () => {
const text = 'This is a very long text that should be truncated';
const result = truncateText(text, 20);
expect(result).toBe('This is a very lo...');
expect(result).toHaveLength(20);
});
it('should truncate without ellipsis when max length is 3 or less', () => {
expect(truncateText('Hello', 3)).toBe('Hel');
expect(truncateText('Hello', 2)).toBe('He');
expect(truncateText('Hello', 1)).toBe('H');
});
it('should use ellipsis starting at max length 4', () => {
const result = truncateText('Hello World', 4);
expect(result).toBe('H...');
expect(result).toHaveLength(4);
});
it('should return empty string when max length is zero', () => {
const result = truncateText('Hello', 0);
expect(result).toBe('');
});
it('should return empty string when max length is negative', () => {
const result = truncateText('Hello', -1);
expect(result).toBe('');
});
it('should handle empty string', () => {
const result = truncateText('', 10);
expect(result).toBe('');
});
});
});
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('MCP server optional dependency', () => {
afterEach(() => {
vi.doUnmock('@modelcontextprotocol/sdk/server/mcp.js');
vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js');
vi.doUnmock('../../../src/util/packageImportErrors');
vi.resetModules();
});
it('explains how to install the MCP SDK when server mode is requested', async () => {
vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => {
throw new Error('Cannot find package @modelcontextprotocol/sdk');
});
vi.doMock('../../../src/util/packageImportErrors', () => ({
isMissingPackageImportError: () => true,
}));
const { createMcpServer } = await import('../../../src/commands/mcp/server');
const createServerPromise = createMcpServer();
await expect(createServerPromise).rejects.toThrow(
'The @modelcontextprotocol/sdk package is required for MCP server support.',
);
await expect(createServerPromise).rejects.toThrow('npm install @modelcontextprotocol/sdk');
});
it('surfaces stdio dependency errors before console logging is muted', async () => {
vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => {
throw new Error('Cannot find package @modelcontextprotocol/sdk');
});
vi.doMock('../../../src/util/packageImportErrors', () => ({
isMissingPackageImportError: () => true,
}));
const loggerModule = await import('../../../src/logger');
const originalTransports = [...loggerModule.default.transports];
const consoleTransport = { constructor: { name: 'Console' }, silent: false };
loggerModule.default.transports.splice(
0,
loggerModule.default.transports.length,
consoleTransport as any,
);
try {
const { startStdioMcpServer } = await import('../../../src/commands/mcp/server');
await expect(startStdioMcpServer()).rejects.toThrow(
'The @modelcontextprotocol/sdk package is required for MCP server support.',
);
expect(consoleTransport.silent).toBe(false);
} finally {
loggerModule.default.transports.splice(
0,
loggerModule.default.transports.length,
...originalTransports,
);
}
});
it('restores stdio console logging when server creation fails after muting', async () => {
vi.doMock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: class {
constructor() {
throw new Error('server create failed');
}
},
}));
vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: class {},
}));
const loggerModule = await import('../../../src/logger');
const originalTransports = [...loggerModule.default.transports];
const consoleTransport = { constructor: { name: 'Console' }, silent: false };
loggerModule.default.transports.splice(
0,
loggerModule.default.transports.length,
consoleTransport as any,
);
try {
const { startStdioMcpServer } = await import('../../../src/commands/mcp/server');
await expect(startStdioMcpServer()).rejects.toThrow('server create failed');
expect(consoleTransport.silent).toBe(false);
} finally {
loggerModule.default.transports.splice(
0,
loggerModule.default.transports.length,
...originalTransports,
);
}
});
});
+321
View File
@@ -0,0 +1,321 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockProcessEnv } from '../../util/utils';
const { mockRandomUUID } = vi.hoisted(() => ({
mockRandomUUID: vi.fn(() => 'secure-mcp-session-id'),
}));
vi.mock('node:crypto', () => ({
randomUUID: mockRandomUUID,
}));
const expressMocks = vi.hoisted(() => {
const close = vi.fn((callback: (error?: Error) => void) => callback());
const listen = vi.fn((_port: number, callback: () => void) => {
callback();
return { close };
});
const app = {
use: vi.fn(),
post: vi.fn(),
get: vi.fn(),
listen,
};
const json = vi.fn();
const express = Object.assign(
vi.fn(() => app),
{ json },
);
return { app, close, express, json, listen };
});
vi.mock('express', () => ({
default: expressMocks.express,
}));
// Mock dependencies before importing the module
vi.mock('../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
transports: [],
},
}));
vi.mock('../../../src/telemetry', () => ({
default: {
record: vi.fn(),
},
}));
// Mock all tool registrations
vi.mock('../../../src/commands/mcp/tools/listEvaluations', () => ({
registerListEvaluationsTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/getEvaluationDetails', () => ({
registerGetEvaluationDetailsTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/validatePromptfooConfig', () => ({
registerValidatePromptfooConfigTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/testProvider', () => ({
registerTestProviderTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/runAssertion', () => ({
registerRunAssertionTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/runEvaluation', () => ({
registerRunEvaluationTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/shareEvaluation', () => ({
registerShareEvaluationTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/generateDataset', () => ({
registerGenerateDatasetTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/generateTestCases', () => ({
registerGenerateTestCasesTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/compareProviders', () => ({
registerCompareProvidersTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/redteamRun', () => ({
registerRedteamRunTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/tools/redteamGenerate', () => ({
registerRedteamGenerateTool: vi.fn(),
}));
vi.mock('../../../src/commands/mcp/resources', () => ({
registerResources: vi.fn(),
}));
// Store the McpServer constructor calls using vi.hoisted
const mcpServerMocks = vi.hoisted(() => {
const mcpServerCalls: Array<{ name: string; version: string; description?: string }> = [];
// Create a mock class that can be instantiated with `new`
const mockMcpServerImplementation = function MockMcpServer(
this: {
connect: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
tool: ReturnType<typeof vi.fn>;
resource: ReturnType<typeof vi.fn>;
},
config: { name: string; version: string; description?: string },
) {
mcpServerCalls.push(config);
this.connect = vi.fn();
this.close = vi.fn().mockResolvedValue(undefined);
this.tool = vi.fn();
this.resource = vi.fn();
};
const MockMcpServer = vi.fn(mockMcpServerImplementation);
return { mcpServerCalls, MockMcpServer, mockMcpServerImplementation };
});
vi.mock('@modelcontextprotocol/sdk/server/mcp.js', () => ({
McpServer: mcpServerMocks.MockMcpServer,
}));
vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
StdioServerTransport: vi.fn().mockImplementation(() => ({})),
}));
const streamableHttpMocks = vi.hoisted(() => ({
constructor: vi.fn().mockImplementation(function MockStreamableHTTPServerTransport() {
return {
handleRequest: vi.fn(),
};
}),
}));
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
StreamableHTTPServerTransport: streamableHttpMocks.constructor,
}));
const { mcpServerCalls } = mcpServerMocks;
describe('MCP Server', () => {
afterEach(() => {
vi.restoreAllMocks();
});
beforeEach(() => {
vi.clearAllMocks();
mcpServerMocks.MockMcpServer.mockReset().mockImplementation(
mcpServerMocks.mockMcpServerImplementation,
);
streamableHttpMocks.constructor.mockClear();
mockRandomUUID.mockClear();
mcpServerCalls.length = 0;
});
describe('createMcpServer', () => {
it('should create server with correct name and version', async () => {
const { createMcpServer } = await import('../../../src/commands/mcp/server');
await createMcpServer();
expect(mcpServerCalls.length).toBe(1);
expect(mcpServerCalls[0].name).toBe('Promptfoo MCP');
expect(mcpServerCalls[0].version).toBe('1.0.0');
});
it('should create server with a description field', async () => {
const { createMcpServer } = await import('../../../src/commands/mcp/server');
await createMcpServer();
expect(mcpServerCalls.length).toBe(1);
expect(mcpServerCalls[0]).toHaveProperty('description');
expect(typeof mcpServerCalls[0].description).toBe('string');
});
it('should have a meaningful description that describes server capabilities', async () => {
const { createMcpServer } = await import('../../../src/commands/mcp/server');
await createMcpServer();
expect(mcpServerCalls.length).toBe(1);
const description = mcpServerCalls[0].description;
// Verify the description contains key information about the server
expect(description).toBeDefined();
expect(description!.length).toBeGreaterThan(50); // Should be descriptive
expect(description).toContain('MCP server');
expect(description).toContain('evaluation');
});
it('should include security testing capabilities in description', async () => {
const { createMcpServer } = await import('../../../src/commands/mcp/server');
await createMcpServer();
expect(mcpServerCalls.length).toBe(1);
const description = mcpServerCalls[0].description;
// The description should mention security/red teaming capabilities
expect(description).toMatch(/red team|security/i);
});
it('should register all expected tools', async () => {
const { createMcpServer } = await import('../../../src/commands/mcp/server');
const { registerListEvaluationsTool } = await import(
'../../../src/commands/mcp/tools/listEvaluations'
);
const { registerGetEvaluationDetailsTool } = await import(
'../../../src/commands/mcp/tools/getEvaluationDetails'
);
const { registerValidatePromptfooConfigTool } = await import(
'../../../src/commands/mcp/tools/validatePromptfooConfig'
);
const { registerTestProviderTool } = await import(
'../../../src/commands/mcp/tools/testProvider'
);
const { registerRunAssertionTool } = await import(
'../../../src/commands/mcp/tools/runAssertion'
);
const { registerRunEvaluationTool } = await import(
'../../../src/commands/mcp/tools/runEvaluation'
);
const { registerShareEvaluationTool } = await import(
'../../../src/commands/mcp/tools/shareEvaluation'
);
const { registerGenerateDatasetTool } = await import(
'../../../src/commands/mcp/tools/generateDataset'
);
const { registerGenerateTestCasesTool } = await import(
'../../../src/commands/mcp/tools/generateTestCases'
);
const { registerCompareProvidersTool } = await import(
'../../../src/commands/mcp/tools/compareProviders'
);
const { registerRedteamRunTool } = await import('../../../src/commands/mcp/tools/redteamRun');
const { registerRedteamGenerateTool } = await import(
'../../../src/commands/mcp/tools/redteamGenerate'
);
const { registerResources } = await import('../../../src/commands/mcp/resources');
await createMcpServer();
// Core evaluation tools
expect(registerListEvaluationsTool).toHaveBeenCalled();
expect(registerGetEvaluationDetailsTool).toHaveBeenCalled();
expect(registerValidatePromptfooConfigTool).toHaveBeenCalled();
expect(registerTestProviderTool).toHaveBeenCalled();
expect(registerRunAssertionTool).toHaveBeenCalled();
expect(registerRunEvaluationTool).toHaveBeenCalled();
expect(registerShareEvaluationTool).toHaveBeenCalled();
// Generation tools
expect(registerGenerateDatasetTool).toHaveBeenCalled();
expect(registerGenerateTestCasesTool).toHaveBeenCalled();
expect(registerCompareProvidersTool).toHaveBeenCalled();
// Redteam tools
expect(registerRedteamRunTool).toHaveBeenCalled();
expect(registerRedteamGenerateTool).toHaveBeenCalled();
// Resources
expect(registerResources).toHaveBeenCalled();
});
it('should track telemetry for MCP server creation', async () => {
const { createMcpServer } = await import('../../../src/commands/mcp/server');
const telemetry = await import('../../../src/telemetry');
await createMcpServer();
expect(telemetry.default.record).toHaveBeenCalledWith('feature_used', {
feature: 'mcp_server',
transport: expect.any(String),
});
});
});
describe('startHttpMcpServer', () => {
it('creates cryptographically random session identifiers', async () => {
const restoreEnv = mockProcessEnv({ MCP_TRANSPORT: undefined });
let shutdown: (() => void) | undefined;
vi.spyOn(process, 'once').mockImplementation(((event: string, listener: () => void) => {
if (event === 'SIGINT') {
shutdown = listener;
}
return process;
}) as typeof process.once);
let serverPromise: Promise<void> | undefined;
try {
const { startHttpMcpServer } = await import('../../../src/commands/mcp/server');
serverPromise = startHttpMcpServer(3100);
await vi.waitFor(() => {
expect(streamableHttpMocks.constructor).toHaveBeenCalledOnce();
});
const transportOptions = streamableHttpMocks.constructor.mock.calls[0][0] as {
sessionIdGenerator: () => string;
};
expect(transportOptions.sessionIdGenerator()).toBe('secure-mcp-session-id');
expect(mockRandomUUID).toHaveBeenCalledOnce();
} finally {
if (shutdown) {
shutdown();
await serverPromise;
}
restoreEnv();
}
});
});
});
@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
// Schema from getEvaluationDetails.ts
const evalIdSchema = z
.string()
.min(1, 'Eval ID cannot be empty')
.regex(/^[a-zA-Z0-9_:-]+$/, 'Invalid eval ID format');
describe('getEvaluationDetails eval ID validation', () => {
describe('valid eval IDs', () => {
it('should accept new format eval IDs with random sequence', () => {
const validIds = [
'eval-8h1-2025-11-15T14:17:18',
'eval-abc-2024-01-01T00:00:00',
'eval-XyZ-2025-12-31T23:59:59',
'eval-123-2025-06-15T12:30:45',
];
validIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
});
it('should accept old format eval IDs without random sequence', () => {
const validIds = [
'eval-2024-10-01T18:24:51',
'eval-2025-01-01T00:00:00',
'eval-2023-12-31T23:59:59',
];
validIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
});
it('should accept eval IDs with underscores', () => {
// Even though current format uses hyphens, be permissive for legacy
const validIds = ['eval_abc123', 'eval_test_123'];
validIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
});
it('should accept simple alphanumeric IDs', () => {
const validIds = ['eval123', 'evalABC', 'eval-test-123'];
validIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
});
});
describe('invalid eval IDs', () => {
it('should reject empty strings', () => {
const result = evalIdSchema.safeParse('');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Eval ID cannot be empty');
}
});
it('should reject IDs with spaces', () => {
const invalidIds = [
'eval 123',
'eval-abc 123',
'eval-2024-10-01T18:24:51 ',
' eval-2024-10-01T18:24:51',
];
invalidIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Invalid eval ID format');
}
});
});
it('should reject IDs with special characters', () => {
const invalidIds = [
'eval@123',
'eval#abc',
'eval$test',
'eval%123',
'eval&test',
'eval*123',
'eval(test)',
'eval+123',
'eval=test',
'eval[123]',
'eval{test}',
'eval/123',
'eval\\test',
'eval|123',
'eval;test',
'eval,123',
'eval.test',
'eval?123',
'eval!test',
];
invalidIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe('Invalid eval ID format');
}
});
});
it('should reject IDs with newlines or tabs', () => {
const invalidIds = ['eval\n123', 'eval\t123', 'eval\r123'];
invalidIds.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(false);
});
});
it('should reject non-string values', () => {
const invalidValues = [null, undefined, 123, {}, [], true];
invalidValues.forEach((value) => {
const result = evalIdSchema.safeParse(value);
expect(result.success).toBe(false);
});
});
});
describe('edge cases', () => {
it('should accept very long eval IDs', () => {
const longId = 'eval-' + 'a'.repeat(100) + '-2025-11-15T14:17:18';
const result = evalIdSchema.safeParse(longId);
expect(result.success).toBe(true);
});
it('should accept IDs with multiple colons', () => {
const id = 'eval:test:2025-11-15T14:17:18';
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
it('should accept IDs with multiple hyphens', () => {
const id = 'eval---test---123';
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
});
describe('regression test for #6222', () => {
it('should accept eval IDs returned by list_evaluations', () => {
// This is the exact format that caused the bug report
const bugReportId = 'eval-8h1-2025-11-15T14:17:18';
const result = evalIdSchema.safeParse(bugReportId);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(bugReportId);
}
});
it('should accept all valid ISO timestamp formats in eval IDs', () => {
// Test various times to ensure colons in time component work
const idsWithTimes = [
'eval-abc-2025-11-15T00:00:00',
'eval-abc-2025-11-15T12:30:45',
'eval-abc-2025-11-15T23:59:59',
'eval-abc-2025-11-15T14:17:18', // From bug report
];
idsWithTimes.forEach((id) => {
const result = evalIdSchema.safeParse(id);
expect(result.success).toBe(true);
});
});
});
});
@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { registerGetEvaluationDetailsTool } from '../../../../src/commands/mcp/tools/getEvaluationDetails';
import { readResult } from '../../../../src/util/database';
vi.mock('../../../../src/util/database', () => ({
readResult: vi.fn(),
}));
vi.mock('../../../../src/commands/mcp/lib/utils', () => ({
createToolResponse: vi.fn((name, success, data) => ({ name, success, data })),
}));
describe('get_evaluation_details filtering', () => {
const getMockEvalData = () => ({
result: {
results: [
{ success: true, error: null, metadata: {} },
{ success: false, error: 'Failed assertion', metadata: {} },
{ success: false, error: 'Runtime error', metadata: {} },
{ success: true, error: null, metadata: { highlighted: true } },
],
table: {
head: {
providers: [],
prompts: [{}, {}],
},
},
},
});
const mockMcpServer = {
tool: vi.fn(),
} as any;
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
it('should filter failures correctly', async () => {
registerGetEvaluationDetailsTool(mockMcpServer);
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
const response = await toolHandler({ id: 'test-eval', filter: 'failures' });
expect(response.success).toBe(true);
const filteredResults = response.data.evaluation.results;
expect(filteredResults).toHaveLength(2);
expect(filteredResults[0].success).toBe(false);
});
it('should filter passes correctly', async () => {
registerGetEvaluationDetailsTool(mockMcpServer);
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
const response = await toolHandler({ id: 'test-eval', filter: 'passes' });
expect(response.success).toBe(true);
const filteredResults = response.data.evaluation.results;
expect(filteredResults).toHaveLength(2);
filteredResults.forEach((r: any) => {
expect(r.success).toBe(true);
expect(r.error).toBeNull();
});
});
it('should filter errors correctly', async () => {
registerGetEvaluationDetailsTool(mockMcpServer);
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
const response = await toolHandler({ id: 'test-eval', filter: 'errors' });
expect(response.success).toBe(true);
const filteredResults = response.data.evaluation.results;
expect(filteredResults).toHaveLength(2);
expect(filteredResults[1].error).toBe('Runtime error');
});
it('should filter highlights correctly', async () => {
registerGetEvaluationDetailsTool(mockMcpServer);
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
const response = await toolHandler({ id: 'test-eval', filter: 'highlights' });
expect(response.success).toBe(true);
const filteredResults = response.data.evaluation.results;
expect(filteredResults).toHaveLength(1);
expect(filteredResults[0].metadata.highlighted).toBe(true);
});
it('should return all results when filter is "all"', async () => {
registerGetEvaluationDetailsTool(mockMcpServer);
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
vi.mocked(readResult).mockResolvedValue(getMockEvalData() as any);
const response = await toolHandler({ id: 'test-eval', filter: 'all' });
expect(response.success).toBe(true);
expect(response.data.evaluation.results).toHaveLength(4);
});
it('redacts Azure Blob SAS tokens from returned evaluation config', async () => {
const sasUri = 'az://account/container/tests.yaml?sp=r&sig=azure-secret';
const storedEvaluation = getMockEvalData() as any;
storedEvaluation.result.config = { tests: sasUri };
registerGetEvaluationDetailsTool(mockMcpServer);
const toolHandler = mockMcpServer.tool.mock.calls[0][2];
vi.mocked(readResult).mockResolvedValue(storedEvaluation);
const response = await toolHandler({ id: 'test-eval', filter: 'all' });
expect(response.data.evaluation.config.tests).toBe(
'az://account/container/tests.yaml?sp=r&sig=%5BREDACTED%5D',
);
expect(storedEvaluation.result.config.tests).toBe(sasUri);
});
});
+389
View File
@@ -0,0 +1,389 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies before importing
vi.mock('../../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const mockReadLastLines = vi.fn();
const mockReadFirstLines = vi.fn();
vi.mock('../../../../src/util/logs', () => ({
getLogDirectory: vi.fn().mockReturnValue('/mock/logs'),
getLogFiles: vi.fn(),
formatFileSize: vi.fn((bytes: number) => {
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
}),
readLastLines: (...args: unknown[]) => mockReadLastLines(...args),
readFirstLines: (...args: unknown[]) => mockReadFirstLines(...args),
}));
vi.mock('fs/promises', () => ({
default: {
stat: vi.fn(),
},
}));
// Import mocked modules
import fs from 'fs/promises';
import { getLogFiles } from '../../../../src/util/logs';
describe('logs MCP tools', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('list_logs tool', () => {
it('should list log files with pagination', async () => {
const mockLogFiles = [
{
name: 'promptfoo-debug-2024-01-15_10-30-00.log',
path: '/mock/logs/promptfoo-debug-2024-01-15_10-30-00.log',
type: 'debug' as const,
size: 1024,
mtime: new Date('2024-01-15T10:30:00Z'),
},
{
name: 'promptfoo-error-2024-01-15_10-30-00.log',
path: '/mock/logs/promptfoo-error-2024-01-15_10-30-00.log',
type: 'error' as const,
size: 512,
mtime: new Date('2024-01-15T10:30:00Z'),
},
];
vi.mocked(getLogFiles).mockResolvedValue(mockLogFiles);
// Import tool after mocks are set up
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
// Create a mock server to capture the registered tool
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerListLogsTool(mockServer as any);
expect(mockServer.tool).toHaveBeenCalledWith(
'list_logs',
expect.any(Object),
expect.any(Function),
);
// Execute the handler
const result = await registeredHandler!({ type: 'all', page: 1, pageSize: 20 });
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(true);
expect(response.data.logs).toHaveLength(2);
expect(response.data.summary.totalFiles).toBe(2);
expect(response.data.summary.debugFiles).toBe(1);
expect(response.data.summary.errorFiles).toBe(1);
});
it('should return empty result when no log files exist', async () => {
vi.mocked(getLogFiles).mockResolvedValue([]);
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerListLogsTool(mockServer as any);
const result = await registeredHandler!({ type: 'all' });
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(true);
expect(response.data.logs).toHaveLength(0);
expect(response.data.summary.message).toContain('No log files found');
});
it('should filter by log type', async () => {
const mockLogFiles = [
{
name: 'promptfoo-debug-2024-01-15_10-30-00.log',
path: '/mock/logs/promptfoo-debug-2024-01-15_10-30-00.log',
type: 'debug' as const,
size: 1024,
mtime: new Date('2024-01-15T10:30:00Z'),
},
];
vi.mocked(getLogFiles).mockResolvedValue(mockLogFiles);
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerListLogsTool(mockServer as any);
const result = await registeredHandler!({ type: 'debug' });
expect(getLogFiles).toHaveBeenCalledWith('debug');
expect(result.isError).toBe(false);
});
it('should handle errors gracefully', async () => {
vi.mocked(getLogFiles).mockRejectedValue(new Error('Permission denied'));
const { registerListLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerListLogsTool(mockServer as any);
const result = await registeredHandler!({ type: 'all' });
expect(result.isError).toBe(true);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(false);
expect(response.error).toContain('Permission denied');
});
});
describe('read_logs tool', () => {
const mockLogFile = {
name: 'promptfoo-debug-2024-01-15_10-30-00.log',
path: '/mock/logs/promptfoo-debug-2024-01-15_10-30-00.log',
type: 'debug' as const,
size: 1024,
mtime: new Date('2024-01-15T10:30:00Z'),
};
it('should read latest log file by default', async () => {
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
// Mock readLastLines to return log lines
const mockLines = ['2024-01-15 [INFO] Test log line 1', '2024-01-15 [ERROR] Test error'];
mockReadLastLines.mockResolvedValue(mockLines);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
const result = await registeredHandler!({});
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(true);
expect(response.data.file.name).toBe(mockLogFile.name);
expect(response.data.content).toContain('Test log line 1');
expect(response.data.metadata.readMode).toBe('tail');
});
it('should find log file by partial name', async () => {
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
mockReadLastLines.mockResolvedValue(['Test line']);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
const result = await registeredHandler!({ file: 'debug-2024-01-15' });
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(true);
});
it('should return error when file not found', async () => {
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
const result = await registeredHandler!({ file: 'nonexistent-file.log' });
expect(result.isError).toBe(true);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(false);
expect(response.error).toContain('not found');
});
it('should return error when no logs exist for type', async () => {
vi.mocked(getLogFiles).mockResolvedValue([]);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
const result = await registeredHandler!({ type: 'error' });
expect(result.isError).toBe(true);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(false);
expect(response.error).toContain('No error log files found');
});
it('should read from head when specified', async () => {
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
mockReadFirstLines.mockResolvedValue(['First line', 'Second line']);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
const result = await registeredHandler!({ head: true, lines: 2 });
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.data.metadata.readMode).toBe('head');
});
it('should filter content with grep pattern', async () => {
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
const mockLines = [
'2024-01-15 [INFO] Normal message',
'2024-01-15 [ERROR] Error occurred',
'2024-01-15 [INFO] Another normal message',
'2024-01-15 [ERROR] Another error',
];
mockReadLastLines.mockResolvedValue(mockLines);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
const result = await registeredHandler!({ grep: 'ERROR' });
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(true);
expect(response.data.content).toContain('ERROR');
expect(response.data.content).not.toContain('Normal message');
expect(response.data.metadata.grepPattern).toBe('ERROR');
});
it('should handle invalid grep pattern gracefully', async () => {
vi.mocked(getLogFiles).mockResolvedValue([mockLogFile]);
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as any);
const mockLines = ['Test [ERROR] line', 'Test [INFO] line'];
mockReadLastLines.mockResolvedValue(mockLines);
const { registerReadLogsTool } = await import('../../../../src/commands/mcp/tools/logs');
let registeredHandler: ((args: any) => Promise<any>) | null = null;
const mockServer = {
tool: vi.fn((_name: string, _schema: any, handler: (args: any) => Promise<any>) => {
registeredHandler = handler;
}),
};
registerReadLogsTool(mockServer as any);
// Invalid regex should fall back to substring match
const result = await registeredHandler!({ grep: '[ERROR' }); // Invalid regex
expect(result.isError).toBe(false);
const response = JSON.parse(result.content[0].text);
expect(response.success).toBe(true);
// Should still match using substring
expect(response.data.content).toContain('[ERROR]');
});
});
describe('registerLogTools', () => {
it('should register both list_logs and read_logs tools', async () => {
const { registerLogTools } = await import('../../../../src/commands/mcp/tools/logs');
const registeredTools: string[] = [];
const mockServer = {
tool: vi.fn((name: string) => {
registeredTools.push(name);
}),
};
registerLogTools(mockServer as any);
expect(registeredTools).toContain('list_logs');
expect(registeredTools).toContain('read_logs');
expect(mockServer.tool).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,357 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock dependencies before importing the module
vi.mock('../../../../src/logger', () => ({
default: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../../../../src/util/config/default', () => ({
loadDefaultConfig: vi.fn().mockResolvedValue({
defaultConfig: {},
defaultConfigPath: 'promptfooconfig.yaml',
}),
}));
vi.mock('../../../../src/util/config/load', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../../src/util/config/load')>()),
resolveConfigs: vi.fn().mockResolvedValue({
config: {},
testSuite: {
prompts: [{ label: 'test-prompt', raw: 'What is 2+2?' }],
providers: [{ id: 'test-provider' }],
tests: [{ vars: { input: 'test' } }],
},
}),
}));
vi.mock('../../../../src/node/doEval', () => ({
doEval: vi.fn().mockResolvedValue({
id: 'test-eval-123',
toEvaluateSummary: vi.fn().mockResolvedValue({
version: 3,
stats: { successes: 1, failures: 0, errors: 0 },
results: [
{
testCase: { description: 'test case 1', assert: [] },
vars: { input: 'test' },
prompt: { label: 'test-prompt', raw: 'What is 2+2?' },
provider: { id: 'test-provider', label: 'Test Provider' },
response: { output: 'The answer is 4' },
success: true,
score: 1,
namedScores: {},
tokenUsage: { total: 10 },
cost: 0.001,
latencyMs: 100,
},
],
prompts: [{ label: 'test-prompt', provider: 'test-provider', metrics: {} }],
}),
}),
}));
describe('runEvaluation tool', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('result formatting', () => {
it('should use shared formatter for pagination', async () => {
const { formatEvaluationResults } = await import(
'../../../../src/commands/mcp/lib/resultFormatter'
);
const mockSummary = {
version: 3,
stats: { successes: 5, failures: 2, errors: 0 },
results: Array.from({ length: 10 }, (_, i) => ({
testCase: { description: `test case ${i}`, assert: [] },
vars: { index: i },
prompt: { label: 'test', raw: 'prompt text' },
provider: { id: 'provider', label: 'Provider' },
response: { output: `response ${i}` },
success: i < 5,
score: i < 5 ? 1 : 0,
namedScores: {},
cost: 0.001,
latencyMs: 100,
})),
prompts: [],
};
// Test default pagination
const result = formatEvaluationResults(mockSummary as any);
expect(result.pagination.totalResults).toBe(10);
expect(result.pagination.limit).toBe(20);
expect(result.pagination.offset).toBe(0);
expect(result.pagination.hasMore).toBe(false);
expect(result.results.length).toBe(10);
});
it('should respect custom pagination options', async () => {
const { formatEvaluationResults } = await import(
'../../../../src/commands/mcp/lib/resultFormatter'
);
const mockSummary = {
version: 3,
stats: { successes: 100, failures: 0, errors: 0 },
results: Array.from({ length: 100 }, (_, i) => ({
testCase: { description: `test case ${i}`, assert: [] },
vars: { index: i },
prompt: { label: 'test', raw: 'prompt text' },
provider: { id: 'provider', label: 'Provider' },
response: { output: `response ${i}` },
success: true,
score: 1,
namedScores: {},
cost: 0.001,
latencyMs: 100,
})),
prompts: [],
};
// Test custom limit and offset
const result = formatEvaluationResults(mockSummary as any, {
resultLimit: 10,
resultOffset: 20,
});
expect(result.pagination.totalResults).toBe(100);
expect(result.pagination.limit).toBe(10);
expect(result.pagination.offset).toBe(20);
expect(result.pagination.hasMore).toBe(true);
expect(result.pagination.returnedCount).toBe(10);
expect(result.results.length).toBe(10);
expect(result.results[0].index).toBe(20);
});
it('should limit results to max 100', async () => {
const { formatEvaluationResults } = await import(
'../../../../src/commands/mcp/lib/resultFormatter'
);
const mockSummary = {
version: 3,
stats: { successes: 200, failures: 0, errors: 0 },
results: Array.from({ length: 200 }, (_, i) => ({
testCase: { description: `test case ${i}`, assert: [] },
vars: { index: i },
prompt: { label: 'test', raw: 'prompt text' },
provider: { id: 'provider', label: 'Provider' },
response: { output: `response ${i}` },
success: true,
score: 1,
namedScores: {},
cost: 0.001,
latencyMs: 100,
})),
prompts: [],
};
// Even if requesting 200, should cap at 100
const result = formatEvaluationResults(mockSummary as any, {
resultLimit: 200,
});
expect(result.pagination.limit).toBe(100);
expect(result.results.length).toBe(100);
});
it('should truncate long text fields', async () => {
const { formatEvaluationResults } = await import(
'../../../../src/commands/mcp/lib/resultFormatter'
);
const longText = 'x'.repeat(500);
const mockSummary = {
version: 3,
stats: { successes: 1, failures: 0, errors: 0 },
results: [
{
testCase: { description: 'test', assert: [] },
vars: {},
prompt: { label: 'test', raw: longText },
provider: { id: 'provider', label: 'Provider' },
response: { output: longText },
success: true,
score: 1,
namedScores: {},
},
],
prompts: [],
};
const result = formatEvaluationResults(mockSummary as any);
expect(result.results[0].prompt.raw.length).toBeLessThan(200);
expect(result.results[0].prompt.raw).toContain('...');
expect(result.results[0].response.output!.length).toBeLessThan(300);
expect(result.results[0].response.output).toContain('...');
});
});
describe('prompts summary formatting', () => {
it('should format prompts summary for version 3', async () => {
const { formatPromptsSummary } = await import(
'../../../../src/commands/mcp/lib/resultFormatter'
);
const mockSummary = {
version: 3,
stats: { successes: 1, failures: 0, errors: 0 },
results: [],
prompts: [
{ label: 'prompt1', provider: 'openai', metrics: { avgScore: 0.95 } },
{ label: 'prompt2', provider: 'anthropic', metrics: { avgScore: 0.88 } },
],
};
const result = formatPromptsSummary(mockSummary as any);
expect(result.length).toBe(2);
expect(result[0].label).toBe('prompt1');
expect(result[0].provider).toBe('openai');
expect(result[0].metrics).toEqual({ avgScore: 0.95 });
});
it('should return empty array for non-version 3 summaries', async () => {
const { formatPromptsSummary } = await import(
'../../../../src/commands/mcp/lib/resultFormatter'
);
const mockSummary = {
version: 2,
stats: { successes: 1, failures: 0, errors: 0 },
results: [],
};
const result = formatPromptsSummary(mockSummary as any);
expect(result).toEqual([]);
});
});
describe('promptFilter validation', () => {
it('should error on mixed numeric and non-numeric filters', async () => {
const { registerRunEvaluationTool } = await import(
'../../../../src/commands/mcp/tools/runEvaluation'
);
let toolHandler: any;
const mockServer = {
tool: vi.fn((_name, _schema, handler) => {
toolHandler = handler;
}),
} as any;
registerRunEvaluationTool(mockServer);
const result = await toolHandler({
configPath: 'test.yaml',
promptFilter: ['0', 'morning.*'],
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Cannot mix numeric indices and regex patterns');
});
it('should return error when all prompts are filtered out', async () => {
const { resolveConfigs } = await import('../../../../src/util/config/load');
vi.mocked(resolveConfigs).mockResolvedValueOnce({
config: {},
testSuite: {
prompts: [{ label: 'test-prompt', raw: 'test' }],
providers: [{ id: 'test-provider' }],
tests: [{ vars: { input: 'test' } }],
},
} as any);
const { registerRunEvaluationTool } = await import(
'../../../../src/commands/mcp/tools/runEvaluation'
);
let toolHandler: any;
registerRunEvaluationTool({
tool: vi.fn((_name, _schema, handler) => {
toolHandler = handler;
}),
} as any);
const result = await toolHandler({
configPath: 'test.yaml',
promptFilter: 'nonexistent.*',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('No prompts found after applying filter');
});
it('should return error when all providers are filtered out', async () => {
const { resolveConfigs } = await import('../../../../src/util/config/load');
vi.mocked(resolveConfigs).mockResolvedValueOnce({
config: {},
testSuite: {
prompts: [{ label: 'test-prompt', raw: 'test' }],
providers: [
{ id: () => 'openai:gpt-4', callApi: async () => ({ output: 'test' }) },
] as any,
tests: [{ vars: { input: 'test' } }],
},
} as any);
const { registerRunEvaluationTool } = await import(
'../../../../src/commands/mcp/tools/runEvaluation'
);
let toolHandler: any;
registerRunEvaluationTool({
tool: vi.fn((_name, _schema, handler) => {
toolHandler = handler;
}),
} as any);
const result = await toolHandler({
configPath: 'test.yaml',
providerFilter: 'nonexistent',
});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('No providers matched filter');
});
it('should return an error when config resolution fails instead of terminating the host', async () => {
const { ConfigResolutionError, resolveConfigs } = await import(
'../../../../src/util/config/load'
);
vi.mocked(resolveConfigs).mockRejectedValueOnce(
new ConfigResolutionError('You must provide at least 1 prompt'),
);
const { registerRunEvaluationTool } = await import(
'../../../../src/commands/mcp/tools/runEvaluation'
);
let toolHandler: any;
registerRunEvaluationTool({
tool: vi.fn((_name, _schema, handler) => {
toolHandler = handler;
}),
} as any);
const result = await toolHandler({
configPath: 'test.yaml',
promptFilter: 'anything',
});
expect(result.isError).toBe(true);
const logger = (await import('../../../../src/logger')).default;
expect(logger.error).toHaveBeenCalledWith(
'Evaluation execution failed: You must provide at least 1 prompt',
);
});
});
});