Hacker News MCP
by pskill9
- hn-server
- src
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
McpError,
} from '@modelcontextprotocol/sdk/types.js';
import axios from 'axios';
import * as cheerio from 'cheerio';
interface Story {
title: string;
url?: string;
points: number;
author: string;
time: string;
commentCount: number;
rank: number;
}
const isValidStoryType = (type: string): boolean => {
return ['top', 'new', 'ask', 'show', 'jobs'].includes(type);
};
class HackerNewsServer {
private server: Server;
private baseUrl = 'https://news.ycombinator.com';
constructor() {
this.server = new Server(
{
name: 'hn-server',
version: '0.1.0',
},
{
capabilities: {
tools: {},
},
}
);
this.setupToolHandlers();
// Error handling
this.server.onerror = (error) => console.error('[MCP Error]', error);
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
private async fetchStories(type: string = 'top'): Promise<Story[]> {
try {
const url = type === 'top' ? this.baseUrl : `${this.baseUrl}/${type}`;
const response = await axios.get(url);
const $ = cheerio.load(response.data);
const stories: Story[] = [];
$('.athing').each((i, elem) => {
const titleRow = $(elem);
const metadataRow = titleRow.next();
const rank = parseInt(titleRow.find('.rank').text(), 10);
const titleElement = titleRow.find('.titleline > a').first();
const title = titleElement.text();
const url = titleElement.attr('href');
const sitebit = titleRow.find('.sitebit');
const points = parseInt(metadataRow.find('.score').text(), 10) || 0;
const author = metadataRow.find('.hnuser').text();
const time = metadataRow.find('.age').attr('title') || '';
const commentText = metadataRow.find('a').last().text();
const commentCount = parseInt(commentText.split(' ')[0]) || 0;
stories.push({
title,
url: url?.startsWith('item?id=') ? `${this.baseUrl}/${url}` : url,
points,
author,
time,
commentCount,
rank
});
});
return stories;
} catch (error) {
if (axios.isAxiosError(error)) {
throw new McpError(
ErrorCode.InternalError,
`Failed to fetch stories: ${error.message}`
);
}
throw error;
}
}
private setupToolHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [
{
name: 'get_stories',
description: 'Get stories from Hacker News',
inputSchema: {
type: 'object',
properties: {
type: {
type: 'string',
description: 'Type of stories to fetch (top, new, ask, show, jobs)',
enum: ['top', 'new', 'ask', 'show', 'jobs'],
default: 'top'
},
limit: {
type: 'number',
description: 'Number of stories to return (max 30)',
minimum: 1,
maximum: 30,
default: 10
}
}
}
}
]
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
if (request.params.name !== 'get_stories') {
throw new McpError(
ErrorCode.MethodNotFound,
`Unknown tool: ${request.params.name}`
);
}
const args = request.params.arguments as { type?: string; limit?: number };
const type = args.type || 'top';
const limit = Math.min(args.limit || 10, 30);
if (!isValidStoryType(type)) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid story type: ${type}. Must be one of: top, new, ask, show, jobs`
);
}
try {
const stories = await this.fetchStories(type);
return {
content: [
{
type: 'text',
text: JSON.stringify(stories.slice(0, limit), null, 2)
}
]
};
} catch (error) {
if (error instanceof McpError) {
throw error;
}
throw new McpError(
ErrorCode.InternalError,
`Failed to fetch stories: ${error}`
);
}
});
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
console.error('Hacker News MCP server running on stdio');
}
}
const server = new HackerNewsServer();
server.run().catch(console.error);