#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
// HackerNews API base URL
const HN_API_BASE = 'https://hn.algolia.com/api/v1';
// Helper function to make API calls
async function fetchHN(endpoint: string): Promise<any> {
const response = await fetch(`${HN_API_BASE}${endpoint}`);
if (!response.ok) {
throw new Error(`HN API error: ${response.status} ${response.statusText}`);
}
return await response.json();
}
// Initialize the MCP server
const server = new McpServer({
name: 'hackernews-mcp-server',
version: '1.0.0'
});
// Tool 1: Search posts by relevance
server.registerTool(
'search-posts',
{
title: 'Search HackerNews Posts',
description: 'Search HackerNews posts by relevance (sorted by relevance, then points, then number of comments)',
inputSchema: {
query: z.string().describe('Search query text'),
tags: z.string().optional().describe('Filter tags (e.g., "story", "comment", "poll", "show_hn", "ask_hn", "front_page", "author_USERNAME", "story_ID")'),
numericFilters: z.string().optional().describe('Numeric filters (e.g., "points>100", "created_at_i>1672531200")'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ query, tags, numericFilters, page, hitsPerPage }) => {
const params = new URLSearchParams();
if (query) params.append('query', query);
if (tags) params.append('tags', tags);
if (numericFilters) params.append('numericFilters', numericFilters);
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 2: Search posts by date
server.registerTool(
'search-posts-by-date',
{
title: 'Search HackerNews Posts by Date',
description: 'Search HackerNews posts sorted by date (most recent first)',
inputSchema: {
query: z.string().optional().describe('Search query text (optional)'),
tags: z.string().optional().describe('Filter tags (e.g., "story", "comment", "poll", "show_hn", "ask_hn", "front_page", "author_USERNAME", "story_ID")'),
numericFilters: z.string().optional().describe('Numeric filters (e.g., "points>100", "created_at_i>1672531200")'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ query, tags, numericFilters, page, hitsPerPage }) => {
const params = new URLSearchParams();
if (query) params.append('query', query);
if (tags) params.append('tags', tags);
if (numericFilters) params.append('numericFilters', numericFilters);
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 3: Get front page posts
server.registerTool(
'get-front-page',
{
title: 'Get HackerNews Front Page',
description: 'Get all stories currently on the HackerNews front page',
inputSchema: {
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'front_page');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 4: Get latest stories
server.registerTool(
'get-latest-stories',
{
title: 'Get Latest HackerNews Stories',
description: 'Get the most recent stories posted to HackerNews',
inputSchema: {
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'story');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 5: Get latest comments
server.registerTool(
'get-latest-comments',
{
title: 'Get Latest HackerNews Comments',
description: 'Get the most recent comments posted to HackerNews',
inputSchema: {
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'comment');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 6: Get Show HN posts
server.registerTool(
'get-show-hn',
{
title: 'Get Show HN Posts',
description: 'Get latest "Show HN" posts where users showcase their projects',
inputSchema: {
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'show_hn');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 7: Get Ask HN posts
server.registerTool(
'get-ask-hn',
{
title: 'Get Ask HN Posts',
description: 'Get latest "Ask HN" posts where users ask questions',
inputSchema: {
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'ask_hn');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 8: Get item by ID
server.registerTool(
'get-item',
{
title: 'Get HackerNews Item by ID',
description: 'Get a specific HackerNews item (story, comment, poll, etc.) by its ID',
inputSchema: {
id: z.number().describe('The ID of the item to retrieve')
},
outputSchema: {
id: z.number(),
created_at: z.string(),
author: z.string().optional(),
title: z.string().optional(),
url: z.string().optional(),
text: z.string().optional(),
points: z.number().optional(),
parent_id: z.number().optional(),
children: z.array(z.any()).optional()
}
},
async ({ id }) => {
const endpoint = `/items/${id}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 9: Get user information
server.registerTool(
'get-user',
{
title: 'Get HackerNews User Info',
description: 'Get information about a specific HackerNews user',
inputSchema: {
username: z.string().describe('The username to look up')
},
outputSchema: {
username: z.string(),
about: z.string().optional(),
karma: z.number()
}
},
async ({ username }) => {
const endpoint = `/users/${username}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 10: Get posts by author
server.registerTool(
'get-posts-by-author',
{
title: 'Get Posts by Author',
description: 'Get all posts (stories, comments, etc.) by a specific author',
inputSchema: {
username: z.string().describe('The username of the author'),
tags: z.string().optional().describe('Filter by type (e.g., "story", "comment")'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ username, tags, page, hitsPerPage }) => {
const params = new URLSearchParams();
const authorTag = `author_${username}`;
const combinedTags = tags ? `${authorTag},${tags}` : authorTag;
params.append('tags', combinedTags);
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 11: Get comments for a story
server.registerTool(
'get-story-comments',
{
title: 'Get Comments for a Story',
description: 'Get all comments for a specific story by story ID',
inputSchema: {
storyId: z.number().describe('The ID of the story'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ storyId, page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', `comment,story_${storyId}`);
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 12: Search by URL
server.registerTool(
'search-by-url',
{
title: 'Search Posts by URL',
description: 'Search for HackerNews posts that link to a specific URL',
inputSchema: {
url: z.string().describe('The URL to search for'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ url, page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('query', url);
params.append('restrictSearchableAttributes', 'url');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 13: Get polls
server.registerTool(
'get-polls',
{
title: 'Get HackerNews Polls',
description: 'Get latest polls posted to HackerNews',
inputSchema: {
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'poll');
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 14: Advanced search with time range
server.registerTool(
'search-by-time-range',
{
title: 'Search Posts by Time Range',
description: 'Search for posts within a specific time range (Unix timestamps)',
inputSchema: {
query: z.string().optional().describe('Search query text (optional)'),
tags: z.string().optional().describe('Filter tags (e.g., "story", "comment")'),
startTime: z.number().describe('Start time in Unix timestamp (seconds)'),
endTime: z.number().describe('End time in Unix timestamp (seconds)'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ query, tags, startTime, endTime, page, hitsPerPage }) => {
const params = new URLSearchParams();
if (query) params.append('query', query);
if (tags) params.append('tags', tags);
params.append('numericFilters', `created_at_i>${startTime},created_at_i<${endTime}`);
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Tool 15: Get top stories (high points)
server.registerTool(
'get-top-stories',
{
title: 'Get Top Stories by Points',
description: 'Get stories with a minimum number of points, sorted by date',
inputSchema: {
minPoints: z.number().optional().default(100).describe('Minimum number of points (default: 100)'),
page: z.number().optional().describe('Page number for pagination (default: 0)'),
hitsPerPage: z.number().optional().describe('Number of results per page (default: 20)')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number(),
page: z.number(),
hitsPerPage: z.number()
}
},
async ({ minPoints = 100, page, hitsPerPage }) => {
const params = new URLSearchParams();
params.append('tags', 'story');
params.append('numericFilters', `points>=${minPoints}`);
if (page !== undefined) params.append('page', page.toString());
if (hitsPerPage !== undefined) params.append('hitsPerPage', hitsPerPage.toString());
const endpoint = `/search_by_date?${params.toString()}`;
const result = await fetchHN(endpoint);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
structuredContent: result
};
}
);
// Connect the server using stdio transport
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Log to stderr so it doesn't interfere with stdio communication
console.error('HackerNews MCP Server running on stdio');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});