/**
* @fileoverview Tests for the ToolRegistry class
*/
import { vi, MockedFunction } from 'vitest';
import { z } from 'zod';
import { ToolRegistry, ToolDefinition } from '../../server/tool-registry.js';
import { BaseHandlerDeps } from '../../handlers/base/handler.interface.js';
import { ApiResponse } from '../../models/common.js';
import { DeepSourceClientFactory } from '../../client/factory.js';
import { Logger } from '../../utils/logging/logger.js';
// Mock the MCP server
vi.mock('@modelcontextprotocol/sdk/server/mcp.js');
// Mock the logger
vi.mock('../../utils/logging/logger.js', () => ({
createLogger: vi.fn(() => ({
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
// Mock the config
vi.mock('../../config/index.js', () => ({
getApiKey: vi.fn(() => 'test-api-key'),
}));
// Mock tool helpers
vi.mock('../../server/tool-helpers.js', () => ({
logToolInvocation: vi.fn(),
logToolResult: vi.fn(),
logAndFormatError: vi.fn((error: unknown, _toolName: string) => {
if (error instanceof Error) {
return `${error.message}`;
}
return `${String(error)}`;
}),
}));
// Mock error formatter
vi.mock('../../utils/error-handling/mcp-error-formatter.js', () => ({
createErrorResponse: vi.fn((_error: unknown) => ({
content: [{ type: 'text', text: JSON.stringify({ error: 'formatted error' }) }],
isError: true,
})),
}));
describe('ToolRegistry', () => {
let mockServer: {
registerTool: MockedFunction<(tool: unknown) => void>;
};
let registry: ToolRegistry;
let mockDeps: BaseHandlerDeps;
beforeEach(() => {
vi.clearAllMocks();
// Create mock server
mockServer = {
registerTool: vi.fn(),
};
// Create mock dependencies without calling createDefaultHandlerDeps
mockDeps = {
clientFactory: {
createClient: vi.fn(),
} as DeepSourceClientFactory,
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
getApiKey: vi.fn(() => 'test-api-key'),
};
// Create registry instance
registry = new ToolRegistry(mockServer, mockDeps);
});
describe('constructor', () => {
it('should initialize with server and default deps', () => {
// Set env var for this test
process.env.DEEPSOURCE_API_KEY = 'test-key';
const newRegistry = new ToolRegistry(mockServer);
expect(newRegistry).toBeDefined();
delete process.env.DEEPSOURCE_API_KEY;
});
it('should initialize with server and custom deps', () => {
const newRegistry = new ToolRegistry(mockServer, mockDeps);
expect(newRegistry).toBeDefined();
});
});
describe('registerTool', () => {
it('should register a simple tool without schemas', () => {
const tool: ToolDefinition = {
name: 'test_tool',
description: 'A test tool',
handler: async () => ({
content: [{ type: 'text', text: 'test result' }],
}),
};
registry.registerTool(tool);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'test_tool',
{ description: 'A test tool' },
expect.any(Function)
);
});
it('should register a tool with input and output schemas', () => {
const inputSchema = z.object({
name: z.string(),
age: z.number(),
});
const outputSchema = z.object({
message: z.string(),
});
const tool: ToolDefinition = {
name: 'user_tool',
description: 'A user tool',
inputSchema,
outputSchema,
handler: async (params) => ({
content: [{ type: 'text', text: JSON.stringify({ message: `Hello ${params.name}` }) }],
}),
};
registry.registerTool(tool);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'user_tool',
{
description: 'A user tool',
inputSchema,
outputSchema,
},
expect.any(Function)
);
});
it('should warn when overwriting an existing tool', () => {
const tool1: ToolDefinition = {
name: 'duplicate_tool',
description: 'First version',
handler: async () => ({ content: [{ type: 'text', text: 'v1' }] }),
};
const tool2: ToolDefinition = {
name: 'duplicate_tool',
description: 'Second version',
handler: async () => ({ content: [{ type: 'text', text: 'v2' }] }),
};
registry.registerTool(tool1);
registry.registerTool(tool2);
expect(mockServer.registerTool).toHaveBeenCalledTimes(2);
});
});
describe('tool execution', () => {
it('should execute handler successfully with valid input', async () => {
const inputSchema = z.object({
message: z.string(),
});
const mockHandler = vi.fn(async (params: { message: string }) => ({
content: [{ type: 'text' as const, text: `Echo: ${params.message}` }],
}));
const tool: ToolDefinition = {
name: 'echo_tool',
description: 'Echoes input',
inputSchema,
handler: mockHandler,
};
registry.registerTool(tool);
// Get the registered handler
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
// Execute with valid input
const result = await registeredHandler({ message: 'Hello' }, {});
expect(result).toEqual({
content: [{ type: 'text', text: 'Echo: Hello' }],
structuredContent: 'Echo: Hello',
isError: false,
});
expect(mockHandler).toHaveBeenCalledWith({ message: 'Hello' });
});
it('should handle input validation errors', async () => {
const inputSchema = z.object({
age: z.number().min(0).max(120),
});
const tool: ToolDefinition = {
name: 'age_tool',
description: 'Validates age',
inputSchema,
handler: async () => ({ content: [{ type: 'text', text: 'OK' }] }),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
// Execute with invalid input
const result = await registeredHandler({ age: 'not a number' }, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid input:');
});
it('should handle handler execution errors', async () => {
const tool: ToolDefinition = {
name: 'error_tool',
description: 'Throws errors',
handler: async () => {
throw new Error('Handler failed');
},
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('Handler failed');
});
it('should handle ApiResponse format from handler', async () => {
const apiResponse: ApiResponse = {
content: [{ type: 'text', text: JSON.stringify({ data: 'test' }) }],
isError: false,
};
const tool: ToolDefinition = {
name: 'api_tool',
description: 'Returns API response',
handler: async () => apiResponse,
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result).toEqual({
content: apiResponse.content,
structuredContent: { data: 'test' },
isError: false,
});
});
it('should handle ApiResponse errors', async () => {
const errorResponse: ApiResponse = {
content: [{ type: 'text', text: JSON.stringify({ error: 'API failed' }) }],
isError: true,
};
const tool: ToolDefinition = {
name: 'api_error_tool',
description: 'Returns API error',
handler: async () => errorResponse,
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.isError).toBe(true);
// The error content is the raw JSON string that was in the ApiResponse
expect(result.content[0].text).toBe('{"error":"API failed"}');
});
it('should handle non-ApiResponse results', async () => {
const tool: ToolDefinition = {
name: 'plain_tool',
description: 'Returns plain object',
handler: async () => ({ data: 'plain result' }),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result).toEqual({
content: [{ type: 'text', text: '{\n "data": "plain result"\n}' }],
structuredContent: { data: 'plain result' },
isError: false,
});
});
it('should handle undefined params', async () => {
const tool: ToolDefinition = {
name: 'no_params_tool',
description: 'No params',
handler: async (params) => ({
content: [{ type: 'text', text: JSON.stringify(params) }],
}),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler(undefined, {});
expect(result.isError).toBe(false);
expect(result.content[0].text).toBe('{}');
});
});
describe('registerTools', () => {
it('should register multiple tools at once', () => {
const tools: ToolDefinition[] = [
{
name: 'tool1',
description: 'First tool',
handler: async () => ({ content: [{ type: 'text', text: '1' }] }),
},
{
name: 'tool2',
description: 'Second tool',
handler: async () => ({ content: [{ type: 'text', text: '2' }] }),
},
{
name: 'tool3',
description: 'Third tool',
handler: async () => ({ content: [{ type: 'text', text: '3' }] }),
},
];
registry.registerTools(tools);
expect(mockServer.registerTool).toHaveBeenCalledTimes(3);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'tool1',
expect.any(Object),
expect.any(Function)
);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'tool2',
expect.any(Object),
expect.any(Function)
);
expect(mockServer.registerTool).toHaveBeenCalledWith(
'tool3',
expect.any(Object),
expect.any(Function)
);
});
});
describe('getTool', () => {
it('should return registered tool', () => {
const tool: ToolDefinition = {
name: 'get_tool',
description: 'Tool to get',
handler: async () => ({ content: [{ type: 'text', text: 'got' }] }),
};
registry.registerTool(tool);
const retrieved = registry.getTool('get_tool');
expect(retrieved).toEqual(tool);
});
it('should return undefined for non-existent tool', () => {
const retrieved = registry.getTool('non_existent');
expect(retrieved).toBeUndefined();
});
});
describe('getToolNames', () => {
it('should return empty array initially', () => {
expect(registry.getToolNames()).toEqual([]);
});
it('should return all registered tool names', () => {
const tools: ToolDefinition[] = [
{ name: 'alpha', description: 'A', handler: async () => ({ content: [] }) },
{ name: 'beta', description: 'B', handler: async () => ({ content: [] }) },
{ name: 'gamma', description: 'C', handler: async () => ({ content: [] }) },
];
registry.registerTools(tools);
const names = registry.getToolNames();
expect(names).toHaveLength(3);
expect(names).toContain('alpha');
expect(names).toContain('beta');
expect(names).toContain('gamma');
});
});
describe('hasTool', () => {
it('should return false for non-existent tool', () => {
expect(registry.hasTool('missing')).toBe(false);
});
it('should return true for registered tool', () => {
registry.registerTool({
name: 'exists',
description: 'Existing tool',
handler: async () => ({ content: [] }),
});
expect(registry.hasTool('exists')).toBe(true);
});
});
describe('updateDefaultDeps', () => {
it('should update default dependencies', () => {
const newDeps = {
clientFactory: {} as DeepSourceClientFactory,
logger: {} as Logger,
getApiKey: vi.fn(),
};
registry.updateDefaultDeps(newDeps);
// No error thrown means success
expect(true).toBe(true);
});
});
describe('edge cases', () => {
it('should handle malformed JSON in ApiResponse', async () => {
const tool: ToolDefinition = {
name: 'malformed_tool',
description: 'Returns malformed JSON',
handler: async () => ({
content: [{ type: 'text' as const, text: 'not valid json' }],
}),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.structuredContent).toBe('not valid json');
});
it('should handle error response with non-JSON text', async () => {
const tool: ToolDefinition = {
name: 'error_text_tool',
description: 'Returns error with plain text',
handler: async () => ({
content: [{ type: 'text' as const, text: 'Plain error message' }],
isError: true,
}),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('Plain error message');
});
it('should handle error response with malformed JSON', async () => {
const tool: ToolDefinition = {
name: 'malformed_json_error_tool',
description: 'Returns error with malformed JSON',
handler: async () => ({
content: [{ type: 'text' as const, text: '{ invalid json }' }],
isError: true,
}),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('{ invalid json }');
expect(result.structuredContent).toEqual({
code: 'HANDLER_ERROR',
message: '{ invalid json }',
});
});
it('should handle empty content array', async () => {
const tool: ToolDefinition = {
name: 'empty_content_tool',
description: 'Returns empty content',
handler: async () => ({
content: [],
}),
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.content).toEqual([]);
});
it('should handle error response parsing failure', async () => {
// Mock createErrorResponse to return malformed JSON that will fail to parse
const { createErrorResponse } = vi.mocked(
await import('../../utils/error-handling/mcp-error-formatter.js')
);
createErrorResponse.mockReturnValueOnce({
content: [{ type: 'text', text: 'malformed{json}' }],
isError: true,
});
const tool: ToolDefinition = {
name: 'parse_error_tool',
description: 'Tool that causes error response parsing to fail',
handler: async () => {
throw new Error('Test error for parsing failure');
},
};
registry.registerTool(tool);
const registeredHandler = mockServer.registerTool.mock.calls[0][2];
const result = await registeredHandler({}, {});
expect(result.isError).toBe(true);
expect(result.content[0].text).toBe('Test error for parsing failure');
// The actual structured content that gets set when JSON parsing fails
expect(result.structuredContent).toEqual({
code: 'HANDLER_ERROR',
message: 'Test error for parsing failure',
});
});
});
});