Hacker News MCP Server

by devabdultech
Verified
  • src
#!/usr/bin/env node import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError, } from "@modelcontextprotocol/sdk/types.js"; import { hnApi } from "./api/hn.js"; import { algoliaApi } from "./api/algolia.js"; import { formatStory } from "./models/story.js"; import { formatComment } from "./models/comment.js"; import { formatUser } from "./models/user.js"; import { validateInput } from "./utils/validation.js"; import { SearchParamsSchema, CommentRequestSchema, CommentsRequestSchema, CommentTreeRequestSchema, UserRequestSchema, } from "./schemas/index.js"; // Create the MCP server const server = new Server( { name: "hackernews-mcp-server", version: "1.2.2", }, { capabilities: { tools: {}, }, } ); // Set up the ListTools request handler server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "search", description: "Search for stories and comments on Hacker News", inputSchema: { type: "object", properties: { query: { type: "string", description: "The search query" }, type: { type: "string", enum: ["all", "story", "comment"], description: "The type of content to search for", default: "all", }, page: { type: "number", description: "The page number", default: 0, }, hitsPerPage: { type: "number", description: "The number of results per page", default: 20, }, }, required: ["query"], }, }, { name: "getStory", description: "Get a single story by ID", inputSchema: { type: "object", properties: { id: { type: "number", description: "The ID of the story" }, }, required: ["id"], }, }, { name: "getStoryWithComments", description: "Get a story with its comments", inputSchema: { type: "object", properties: { id: { type: "number", description: "The ID of the story" }, }, required: ["id"], }, }, { name: "getStories", description: "Get multiple stories by type (top, new, best, ask, show, job)", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["top", "new", "best", "ask", "show", "job"], description: "The type of stories to fetch", }, limit: { type: "number", description: "The maximum number of stories to fetch", default: 30, }, }, required: ["type"], }, }, { name: "getComment", description: "Get a single comment by ID", inputSchema: { type: "object", properties: { id: { type: "number", description: "The ID of the comment" }, }, required: ["id"], }, }, { name: "getComments", description: "Get comments for a story", inputSchema: { type: "object", properties: { storyId: { type: "number", description: "The ID of the story" }, limit: { type: "number", description: "The maximum number of comments to fetch", default: 30, }, }, required: ["storyId"], }, }, { name: "getCommentTree", description: "Get a comment tree for a story", inputSchema: { type: "object", properties: { storyId: { type: "number", description: "The ID of the story" }, }, required: ["storyId"], }, }, { name: "getUser", description: "Get a user profile by ID", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the user" }, }, required: ["id"], }, }, { name: "getUserSubmissions", description: "Get a user's submissions", inputSchema: { type: "object", properties: { id: { type: "string", description: "The ID of the user" }, }, required: ["id"], }, }, ], }; }); // Set up the CallTool request handler server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: args } = request.params; switch (name) { case "search": { const validatedArgs = validateInput(SearchParamsSchema, args); const { query, type, page, hitsPerPage } = validatedArgs; const tags = type === "all" ? undefined : type; const results = await algoliaApi.search(query, { tags, page, hitsPerPage, }); const hits = results.hits || []; const text = hits .map( (hit: any, index: number) => `${index + 1}. ${hit.title}\n` + ` ID: ${hit.objectID}\n` + ` URL: ${hit.url || "(text post)"}\n` + ` Points: ${hit.points} | Author: ${hit.author} | Comments: ${hit.num_comments}\n\n` ) .join(""); return { content: [{ type: "text", text: text.trim() }], }; } case "getStory": { const { id } = args as { id: number }; const item = await hnApi.getItem(id); if (!item || item.type !== "story") { throw new McpError( ErrorCode.InvalidParams, `Story with ID ${id} not found` ); } const story = formatStory(item); const text = `Story ID: ${story.id}\n` + `Title: ${story.title}\n` + `URL: ${story.url || "(text post)"}\n` + `Points: ${story.score} | Author: ${story.by} | Comments: ${story.descendants}\n` + (story.text ? `\nContent:\n${story.text}\n` : ""); return { content: [{ type: "text", text: text.trim() }], }; } case "getStoryWithComments": { const { id } = args as { id: number }; try { const data = await algoliaApi.getStoryWithComments(id); if (!data || !data.title) { throw new McpError( ErrorCode.InvalidParams, `Story with ID ${id} not found` ); } const formatCommentTree = (comment: any, depth = 0): string => { const indent = " ".repeat(depth); let text = `${indent}Comment by ${comment.author} (ID: ${comment.id}):\n`; text += `${indent}${comment.text}\n`; text += `${indent}Posted: ${comment.created_at}\n\n`; if (comment.children) { text += comment.children .map((child: any) => formatCommentTree(child, depth + 1)) .join(""); } return text; }; const text = `Story ID: ${data.id}\n` + `Title: ${data.title}\n` + `URL: ${data.url || "(text post)"}\n` + `Points: ${data.points} | Author: ${data.author}\n\n` + `Comments:\n` + (data.children || []) .map((comment: any) => formatCommentTree(comment)) .join(""); return { content: [{ type: "text", text: text.trim() }], }; } catch (err) { const error = err as Error; throw new McpError( ErrorCode.InternalError, `Failed to fetch story: ${error.message}` ); } } case "getStories": { const { type, limit = 30 } = args as { type: "top" | "new" | "best" | "ask" | "show" | "job"; limit?: number; }; try { let storyIds: number[] = []; switch (type) { case "top": storyIds = await hnApi.getTopStories(limit); break; case "new": storyIds = await hnApi.getNewStories(limit); break; case "best": storyIds = await hnApi.getBestStories(limit); break; case "ask": storyIds = await hnApi.getAskStories(limit); break; case "show": storyIds = await hnApi.getShowStories(limit); break; case "job": storyIds = await hnApi.getJobStories(limit); break; } const items = await hnApi.getItems(storyIds); const stories = items .filter((item) => item && item.type === "story") .map(formatStory); if (stories.length === 0) { return { content: [{ type: "text", text: "No stories found." }], }; } const text = stories .map( (story, index) => `${index + 1}. ${story.title}\n` + ` ID: ${story.id}\n` + ` URL: ${story.url || "(text post)"}\n` + ` Points: ${story.score} | Author: ${story.by} | Comments: ${story.descendants}\n\n` ) .join(""); return { content: [{ type: "text", text: text.trim() }], }; } catch (err) { const error = err as Error; throw new McpError( ErrorCode.InternalError, `Failed to fetch stories: ${error.message}` ); } } case "getComment": { const validatedArgs = validateInput(CommentRequestSchema, args); const { id } = validatedArgs; const item = await hnApi.getItem(id); if (!item || item.type !== "comment") { throw new McpError( ErrorCode.InvalidParams, `Comment with ID ${id} not found` ); } const comment = formatComment(item); const text = `Comment ID: ${comment.id}\n` + `Comment by ${comment.by}:\n` + `${comment.text}\n` + `Parent ID: ${comment.parent}\n`; return { content: [{ type: "text", text: text.trim() }], }; } case "getComments": { const validatedArgs = validateInput(CommentsRequestSchema, args); const { storyId, limit = 30 } = validatedArgs; try { const story = await hnApi.getItem(storyId); if (!story || !story.kids || story.kids.length === 0) { return { content: [ { type: "text", text: `No comments found for story ID: ${storyId}`, }, ], }; } const commentIds = story.kids.slice(0, limit); const comments = await hnApi.getItems(commentIds); const formattedComments = comments .filter((item) => item && item.type === "comment") .map(formatComment); if (formattedComments.length === 0) { return { content: [ { type: "text", text: `No comments found for story ID: ${storyId}`, }, ], }; } const text = `Comments for Story ID: ${storyId}\n\n` + formattedComments .map( (comment, index) => `${index + 1}. Comment by ${comment.by} (ID: ${ comment.id }):\n` + ` ${comment.text}\n\n` ) .join(""); return { content: [{ type: "text", text: text.trim() }], }; } catch (err) { const error = err as Error; throw new McpError( ErrorCode.InternalError, `Failed to fetch comments: ${error.message}` ); } } case "getCommentTree": { const validatedArgs = validateInput(CommentTreeRequestSchema, args); const { storyId } = validatedArgs; try { const data = await algoliaApi.getStoryWithComments(storyId); if (!data || !data.children || data.children.length === 0) { return { content: [ { type: "text", text: `No comments found for story ID: ${storyId}`, }, ], }; } const formatCommentTree = (comment: any, depth = 0): string => { const indent = " ".repeat(depth); let text = `${indent}Comment by ${comment.author} (ID: ${comment.id}):\n`; text += `${indent}${comment.text}\n\n`; if (comment.children) { text += comment.children .map((child: any) => formatCommentTree(child, depth + 1)) .join(""); } return text; }; const text = `Comment tree for Story ID: ${storyId}\n\n` + data.children .map((comment: any) => formatCommentTree(comment)) .join(""); return { content: [{ type: "text", text: text.trim() }], }; } catch (err) { const error = err as Error; throw new McpError( ErrorCode.InternalError, `Failed to fetch comment tree: ${error.message}` ); } } case "getUser": { const validatedArgs = validateInput(UserRequestSchema, args); const { id } = validatedArgs; const user = await hnApi.getUser(id); if (!user) { throw new McpError( ErrorCode.InvalidParams, `User with ID ${id} not found` ); } const formattedUser = formatUser(user); const text = `User Profile:\n` + `Username: ${formattedUser.id}\n` + `Karma: ${formattedUser.karma}\n` + `Created: ${new Date(formattedUser.created * 1000).toISOString()}\n` + (formattedUser.about ? `\nAbout:\n${formattedUser.about}\n` : ""); return { content: [{ type: "text", text: text.trim() }], }; } case "getUserSubmissions": { const validatedArgs = validateInput(UserRequestSchema, args); const { id } = validatedArgs; const results = await algoliaApi.search("", { tags: `author_${id}`, hitsPerPage: 50, }); const hits = results.hits || []; const text = `Submissions by ${id}:\n\n` + hits .map( (hit: any, index: number) => `${index + 1}. ${hit.title || hit.comment_text}\n` + ` ID: ${hit.objectID}\n` + ` Points: ${hit.points || 0} | Posted: ${hit.created_at}\n\n` ) .join(""); return { content: [{ type: "text", text: text.trim() }], }; } default: throw new McpError(ErrorCode.MethodNotFound, `Tool '${name}' not found`); } }); // Connect to the transport async function runServer() { try { console.error("Initializing server..."); const transport = new StdioServerTransport(); // Connect transport await server.connect(transport); console.error("Server started and connected successfully"); // Handle process signals process.on("SIGINT", () => { console.error("Received SIGINT, shutting down..."); transport.close(); process.exit(0); }); process.on("SIGTERM", () => { console.error("Received SIGTERM, shutting down..."); transport.close(); process.exit(0); }); console.error("Hacker News MCP Server running on stdio"); } catch (error: unknown) { console.error("Error starting server:", error); process.exit(1); } } runServer().catch((error: unknown) => { console.error("Fatal error in main():", error); process.exit(1); });