hn-mcp
Version:
Hacker News (YC) MCP server for Claude & AI assistants. Browse HN stories, search posts, read comments, analyze users.
246 lines (243 loc) • 9.86 kB
JavaScript
/**
* Hacker News MCP Server implementation
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
import { createServer } from 'http';
import { zodToJsonSchema } from 'zod-to-json-schema';
import { CacheManager } from './core/cache.js';
import { RateLimiter } from './core/rate-limiter.js';
import { HackerNewsAPI } from './services/hn-api.js';
import { HackerNewsTools, browseStoriesSchema, searchHNSchema, getStoryDetailsSchema, userAnalysisSchema, hnExplainSchema, } from './tools/index.js';
export const SERVER_NAME = 'hacker-news-mcp';
export const SERVER_VERSION = '1.0.0';
/**
* Create MCP server
*/
export async function createMCPServer() {
const disableCache = process.env.HN_MCP_NO_CACHE === 'true';
const rateLimit = 300; // HN has no official rate limits, being respectful with 5 req/sec
const cacheTTL = 5 * 60 * 1000;
console.error(`🚀 Hacker News MCP Server v${SERVER_VERSION}`);
console.error(`⏱️ Rate limit: ${rateLimit} requests/minute`);
console.error(`💾 Cache: ${disableCache ? 'Disabled' : `TTL ${cacheTTL / 60000} minutes`}`);
const cacheManager = new CacheManager({
defaultTTL: disableCache ? 0 : cacheTTL,
maxSize: disableCache ? 0 : 50 * 1024 * 1024,
});
const rateLimiter = new RateLimiter({
limit: rateLimit,
window: 60000,
name: 'HN API',
});
const hnAPI = new HackerNewsAPI({
rateLimiter,
cacheManager,
});
const tools = new HackerNewsTools(hnAPI);
const server = new Server({
name: SERVER_NAME,
version: SERVER_VERSION,
description: `Hacker News browser and analyzer. Access stories, comments, and user data.
KEY CONCEPTS:
- Story Types: "top" (highest score), "new" (recent), "best" (curated), "ask" (Ask HN), "show" (Show HN), "job" (hiring)
- Search: Full-text search across stories and comments using Algolia
- Story IDs: Numeric identifiers from HN URLs (e.g., 12345678)
- Usernames: HN usernames without @ prefix
COMMON QUERIES:
- "What's trending on HN?" → browse_stories with type="top"
- "Show me Ask HN posts" → browse_stories with type="ask"
- "Search for AI discussions" → search_hn with query="artificial intelligence"
- "Get story with comments" → get_story_details with story ID
- "Analyze HN user" → user_analysis with username
Rate limit: ${rateLimit} requests/minute (HN has no official limits). Cache TTL: ${cacheTTL / 60000} minutes.`,
}, {
capabilities: {
tools: {},
},
});
const toolDefinitions = [
{
name: 'browse_stories',
description: 'Browse Hacker News stories by type. Returns story list with scores, comments, and metadata.',
inputSchema: zodToJsonSchema(browseStoriesSchema),
},
{
name: 'search_hn',
description: 'Search Hacker News stories and comments. Returns matching content with relevance scores.',
inputSchema: zodToJsonSchema(searchHNSchema),
},
{
name: 'get_story_details',
description: 'Get a Hacker News story with its comments. Fetches full story content and comment threads.',
inputSchema: zodToJsonSchema(getStoryDetailsSchema),
},
{
name: 'user_analysis',
description: "Analyze a Hacker News user's profile, karma, and recent submissions.",
inputSchema: zodToJsonSchema(userAnalysisSchema),
},
{
name: 'hn_explain',
description: 'Get explanations of Hacker News terms, culture, and conventions.',
inputSchema: zodToJsonSchema(hnExplainSchema),
},
];
const handlers = {
'tools/list': async () => ({
tools: toolDefinitions,
}),
'tools/call': async (params) => {
const { name, arguments: args } = params;
try {
let result;
switch (name) {
case 'browse_stories':
result = await tools.browseStories(browseStoriesSchema.parse(args));
break;
case 'search_hn':
result = await tools.searchHN(searchHNSchema.parse(args));
break;
case 'get_story_details':
result = await tools.getStoryDetails(getStoryDetailsSchema.parse(args));
break;
case 'user_analysis':
result = await tools.userAnalysis(userAnalysisSchema.parse(args));
break;
case 'hn_explain':
result = await tools.hnExplain(hnExplainSchema.parse(args));
break;
default:
throw new Error(`Unknown tool: ${name}`);
}
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
catch (error) {
return {
content: [
{
type: 'text',
text: `Error: ${error.message}`,
},
],
isError: true,
};
}
},
};
server.setRequestHandler(ListToolsRequestSchema, handlers['tools/list']);
server.setRequestHandler(CallToolRequestSchema, async (request) => {
return handlers['tools/call'](request.params);
});
return { server, cacheManager, tools, handlers };
}
/**
* Start server with stdio transport
*/
export async function startStdioServer() {
const { server, cacheManager } = await createMCPServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('✅ Hacker News MCP Server running (stdio mode)');
console.error('💡 Reading from stdin, writing to stdout');
process.on('SIGINT', () => {
cacheManager.destroy();
process.exit(0);
});
process.on('SIGTERM', () => {
cacheManager.destroy();
process.exit(0);
});
}
/**
* Start server with streamable HTTP transport
*/
export async function startHttpServer(port = 3000) {
const { server, cacheManager } = await createMCPServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: false,
});
await server.connect(transport);
const httpServer = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, MCP-Session-Id');
res.setHeader('Access-Control-Expose-Headers', 'MCP-Session-Id');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (req.url === '/health' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
server: SERVER_NAME,
version: SERVER_VERSION,
protocol: 'MCP',
transport: 'streamable-http',
}));
return;
}
if (req.url === '/' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hacker News MCP Server (Streamable HTTP)\n');
return;
}
if (req.url === '/mcp') {
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => {
body += chunk.toString();
});
req.on('end', async () => {
try {
const parsedBody = JSON.parse(body);
await transport.handleRequest(req, res, parsedBody);
}
catch (error) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32700,
message: 'Parse error'
},
id: null
}));
}
});
}
else {
await transport.handleRequest(req, res);
}
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found\n');
});
httpServer.listen(port, () => {
console.error(`✅ Hacker News MCP Server running (Streamable HTTP)`);
console.error(`🌐 Base URL: http://localhost:${port}`);
console.error(`📡 MCP endpoint: http://localhost:${port}/mcp`);
console.error(`🔌 Connect with web clients or Postman`);
});
const cleanup = () => {
cacheManager.destroy();
httpServer.close();
process.exit(0);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
}
//# sourceMappingURL=mcp-server.js.map