UNPKG

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
/** * 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