/**
* Unit tests for MCP Tools
*/
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HNAPIError } from '../../src/lib/errors.js';
import { HNClient } from '../../src/lib/hn-client.js';
import { registerGetAskHN } from '../../src/tools/get-ask-hn.js';
import { registerGetFrontPage } from '../../src/tools/get-front-page.js';
import { registerGetLatestStories } from '../../src/tools/get-latest-stories.js';
import { registerGetShowHN } from '../../src/tools/get-show-hn.js';
import { registerGetStory } from '../../src/tools/get-story.js';
import { registerGetUser } from '../../src/tools/get-user.js';
import { registerSearchByDate } from '../../src/tools/search-by-date.js';
import { registerSearchComments } from '../../src/tools/search-comments.js';
import { registerSearchStories } from '../../src/tools/search-stories.js';
// Mock crypto.randomUUID
Object.defineProperty(global, 'crypto', {
value: {
randomUUID: vi.fn(() => 'test-uuid'),
},
});
describe('MCP Tools', () => {
let mockServer: McpServer;
let mockHnClient: any; // eslint-disable-line @typescript-eslint/no-explicit-any
let registerToolSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
// Mock server
registerToolSpy = vi.fn();
mockServer = {
registerTool: registerToolSpy,
} as unknown as McpServer;
// Mock HN client
mockHnClient = {
search: vi.fn(),
searchByDate: vi.fn(),
getItem: vi.fn(),
getUser: vi.fn(),
} as any; // eslint-disable-line @typescript-eslint/no-explicit-any
});
describe('registerSearchStories', () => {
it('should register search_stories tool with correct schema', () => {
registerSearchStories(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema, toolHandler] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('search_stories');
expect(toolSchema.title).toBe('Search Stories');
expect(toolSchema.description).toContain('Search Hacker News stories by relevance');
expect(toolSchema.inputSchema.query._def.description).toContain('Search query text');
expect(toolSchema.inputSchema.tags._def.description).toContain('Optional filter tags');
expect(typeof toolHandler).toBe('function');
});
it('should handle successful search', async () => {
registerSearchStories(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Test Story' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 20,
processingTimeMS: 10,
};
mockHnClient.search.mockResolvedValue(mockResult);
const result = await toolHandler({
query: 'test query',
tags: 'story',
numericFilters: 'points>=10',
page: 0,
hitsPerPage: 20,
});
expect(mockHnClient.search).toHaveBeenCalledWith({
query: 'test query',
tags: 'story',
numericFilters: 'points>=10',
page: 0,
hitsPerPage: 20,
});
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify(mockResult, null, 2) }],
structuredContent: mockResult,
});
});
it('should handle search with minimal parameters', async () => {
registerSearchStories(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [],
nbHits: 0,
nbPages: 0,
page: 0,
hitsPerPage: 20,
processingTimeMS: 5,
};
mockHnClient.search.mockResolvedValue(mockResult);
const result = await toolHandler({ query: 'test' });
expect(mockHnClient.search).toHaveBeenCalledWith({
query: 'test',
page: 0,
hitsPerPage: 20,
});
expect(result.content[0].text).toBe(JSON.stringify(mockResult, null, 2));
});
it('should handle HNAPIError gracefully', async () => {
registerSearchStories(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Search failed', 500, 'Server error');
mockHnClient.search.mockRejectedValue(apiError);
const result = await toolHandler({ query: 'test' });
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Search failed' }) }],
isError: true,
});
});
it('should rethrow non-HNError', async () => {
registerSearchStories(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const unexpectedError = new Error('Unexpected error');
mockHnClient.search.mockRejectedValue(unexpectedError);
await expect(toolHandler({ query: 'test' })).rejects.toThrow('Unexpected error');
});
});
describe('registerGetStory', () => {
it('should register get_story tool with correct schema', () => {
registerGetStory(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema, toolHandler] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('get_story');
expect(toolSchema.title).toBe('Get Story');
expect(toolSchema.description).toContain('Retrieve a specific story by ID');
expect(toolSchema.inputSchema.id._def.description).toContain('Story ID (numeric)');
expect(typeof toolHandler).toBe('function');
});
it('should handle successful story retrieval', async () => {
registerGetStory(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
id: 123,
title: 'Test Story',
author: 'testuser',
points: 42,
children: [],
};
mockHnClient.getItem.mockResolvedValue(mockResult);
const result = await toolHandler({ id: 123 });
expect(mockHnClient.getItem).toHaveBeenCalledWith(123);
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify(mockResult, null, 2) }],
structuredContent: mockResult,
});
});
it('should handle HNAPIError gracefully', async () => {
registerGetStory(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Story not found', 404, 'Not found');
mockHnClient.getItem.mockRejectedValue(apiError);
const result = await toolHandler({ id: 999 });
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Story not found' }) }],
isError: true,
});
});
});
describe('registerGetUser', () => {
it('should register get_user tool with correct schema', () => {
registerGetUser(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('get_user');
expect(toolSchema.title).toBe('Get User Profile');
expect(toolSchema.description).toContain('Retrieve a user profile by username');
});
it('should handle successful user retrieval', async () => {
registerGetUser(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
username: 'testuser',
karma: 1234,
about: 'Test user bio',
};
mockHnClient.getUser.mockResolvedValue(mockResult);
const result = await toolHandler({ username: 'testuser' });
expect(mockHnClient.getUser).toHaveBeenCalledWith('testuser');
expect(result.content[0].text).toContain('"username": "testuser"');
});
});
describe('registerGetFrontPage', () => {
it('should register get_front_page tool with correct schema', () => {
registerGetFrontPage(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('get_front_page');
expect(toolSchema.title).toBe('Get Front Page Stories');
expect(typeof toolSchema.inputSchema.page._def).toBe('object');
});
it('should handle successful front page retrieval', async () => {
registerGetFrontPage(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Front Page Story' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 30,
processingTimeMS: 10,
};
mockHnClient.search.mockResolvedValue(mockResult);
const result = await toolHandler({});
expect(mockHnClient.search).toHaveBeenCalledWith({
query: '',
tags: 'front_page',
page: 0,
hitsPerPage: 30,
});
expect(result.content[0].text).toContain('Front Page Story');
});
it('should handle HNAPIError gracefully', async () => {
registerGetFrontPage(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Front page retrieval failed', 500, 'Server error');
mockHnClient.search.mockRejectedValue(apiError);
const result = await toolHandler({});
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Front page retrieval failed' }) }],
isError: true,
});
});
});
describe('registerGetLatestStories', () => {
it('should register get_latest_stories tool with correct schema', () => {
registerGetLatestStories(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('get_latest_stories');
expect(toolSchema.title).toBe('Get Latest Stories');
});
it('should handle successful latest stories retrieval', async () => {
registerGetLatestStories(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Latest Story' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 20,
processingTimeMS: 10,
};
mockHnClient.searchByDate.mockResolvedValue(mockResult);
const result = await toolHandler({});
expect(mockHnClient.searchByDate).toHaveBeenCalledWith({
query: '',
tags: 'story',
page: 0,
hitsPerPage: 20,
});
expect(result.content[0].text).toContain('Latest Story');
});
it('should handle HNAPIError gracefully', async () => {
registerGetLatestStories(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Latest stories retrieval failed', 500, 'Server error');
mockHnClient.searchByDate.mockRejectedValue(apiError);
const result = await toolHandler({});
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Latest stories retrieval failed' }) }],
isError: true,
});
});
});
describe('registerGetAskHN', () => {
it('should register get_ask_hn tool with correct schema', () => {
registerGetAskHN(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('get_ask_hn');
expect(toolSchema.title).toBe('Get Ask HN Posts');
});
it('should handle successful Ask HN retrieval', async () => {
registerGetAskHN(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Ask HN Question' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 20,
processingTimeMS: 10,
};
mockHnClient.searchByDate.mockResolvedValue(mockResult);
const result = await toolHandler({});
expect(mockHnClient.searchByDate).toHaveBeenCalledWith({
query: '',
tags: 'ask_hn',
page: 0,
hitsPerPage: 20,
});
expect(result.content[0].text).toContain('Ask HN Question');
});
it('should handle HNAPIError gracefully', async () => {
registerGetAskHN(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Ask HN retrieval failed', 500, 'Server error');
mockHnClient.searchByDate.mockRejectedValue(apiError);
const result = await toolHandler({});
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Ask HN retrieval failed' }) }],
isError: true,
});
});
});
describe('registerGetShowHN', () => {
it('should register get_show_hn tool with correct schema', () => {
registerGetShowHN(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('get_show_hn');
expect(toolSchema.title).toBe('Get Show HN Posts');
});
it('should handle successful Show HN retrieval', async () => {
registerGetShowHN(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Show HN Project' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 20,
processingTimeMS: 10,
};
mockHnClient.searchByDate.mockResolvedValue(mockResult);
const result = await toolHandler({});
expect(mockHnClient.searchByDate).toHaveBeenCalledWith({
query: '',
tags: 'show_hn',
page: 0,
hitsPerPage: 20,
});
expect(result.content[0].text).toContain('Show HN Project');
});
it('should handle HNAPIError gracefully', async () => {
registerGetShowHN(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Show HN retrieval failed', 500, 'Server error');
mockHnClient.searchByDate.mockRejectedValue(apiError);
const result = await toolHandler({});
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Show HN retrieval failed' }) }],
isError: true,
});
});
});
describe('registerSearchByDate', () => {
it('should register search_by_date tool with correct schema', () => {
registerSearchByDate(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('search_by_date');
expect(toolSchema.title).toBe('Search by Date');
});
it('should handle successful search by date', async () => {
registerSearchByDate(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Dated Story' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 20,
processingTimeMS: 10,
};
mockHnClient.searchByDate.mockResolvedValue(mockResult);
const result = await toolHandler({ query: 'test' });
expect(mockHnClient.searchByDate).toHaveBeenCalledWith({
query: 'test',
page: 0,
hitsPerPage: 20,
});
expect(result.content[0].text).toContain('Dated Story');
});
it('should handle HNAPIError gracefully', async () => {
registerSearchByDate(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Search by date failed', 500, 'Server error');
mockHnClient.searchByDate.mockRejectedValue(apiError);
const result = await toolHandler({ query: 'test' });
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Search by date failed' }) }],
isError: true,
});
});
});
describe('registerSearchComments', () => {
it('should register search_comments tool with correct schema', () => {
registerSearchComments(mockServer, mockHnClient);
expect(registerToolSpy).toHaveBeenCalledTimes(1);
const [toolName, toolSchema] = registerToolSpy.mock.calls[0];
expect(toolName).toBe('search_comments');
expect(toolSchema.title).toBe('Search Comments');
});
it('should handle successful comment search', async () => {
registerSearchComments(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const mockResult = {
hits: [{ id: 1, title: 'Comment Result' }],
nbHits: 1,
nbPages: 1,
page: 0,
hitsPerPage: 20,
processingTimeMS: 10,
};
mockHnClient.search.mockResolvedValue(mockResult);
const result = await toolHandler({ query: 'test' });
expect(mockHnClient.search).toHaveBeenCalledWith({
query: 'test',
tags: 'comment',
page: 0,
hitsPerPage: 20,
});
expect(result.content[0].text).toContain('Comment Result');
});
it('should handle HNAPIError gracefully', async () => {
registerSearchComments(mockServer, mockHnClient);
const [, , toolHandler] = registerToolSpy.mock.calls[0];
const apiError = new HNAPIError('Comment search failed', 500, 'Server error');
mockHnClient.search.mockRejectedValue(apiError);
const result = await toolHandler({ query: 'test' });
expect(result).toEqual({
content: [{ type: 'text', text: JSON.stringify({ error: 'Comment search failed' }) }],
isError: true,
});
});
});
});