├── src ├── models │ ├── user.ts │ ├── comment.ts │ └── story.ts ├── utils │ └── validation.ts ├── schemas │ └── index.ts ├── api │ ├── algolia.ts │ └── hn.ts └── index.ts ├── .npmignore ├── Dockerfile ├── tsconfig.json ├── smithery.yaml ├── .gitignore ├── LICENSE ├── package.json └── README.md /src/models/user.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | id: string; 3 | created: number; 4 | karma: number; 5 | about?: string; 6 | submitted?: number[]; 7 | } 8 | 9 | export function formatUser(user: any): User { 10 | return { 11 | id: user.id, 12 | created: user.created, 13 | karma: user.karma, 14 | about: user.about, 15 | submitted: user.submitted, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Source files 2 | src/ 3 | 4 | # TypeScript config 5 | tsconfig.json 6 | 7 | # Development files 8 | .git/ 9 | .gitignore 10 | .env 11 | .env.* 12 | 13 | # IDE files 14 | .vscode/ 15 | .idea/ 16 | 17 | # Build artifacts 18 | *.log 19 | npm-debug.log* 20 | 21 | # Test files 22 | __tests__/ 23 | *.test.ts 24 | *.spec.ts 25 | 26 | # Documentation 27 | docs/ 28 | 29 | # Docker 30 | Dockerfile 31 | .dockerignore 32 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM node:20-alpine 3 | 4 | WORKDIR /app 5 | 6 | # Copy package files 7 | COPY package*.json ./ 8 | 9 | # Install dependencies 10 | RUN npm install 11 | 12 | # Copy source code 13 | COPY tsconfig.json ./ 14 | COPY src/ ./src/ 15 | 16 | # Build the TypeScript code 17 | RUN npm run build 18 | 19 | # Set the entrypoint 20 | ENTRYPOINT ["node", "dist/index.js"] 21 | -------------------------------------------------------------------------------- /src/models/comment.ts: -------------------------------------------------------------------------------- 1 | export interface Comment { 2 | id: number; 3 | text: string; 4 | by: string; 5 | time: number; 6 | parent: number; 7 | kids?: number[]; 8 | type: "comment"; 9 | } 10 | 11 | export function formatComment(item: any): Comment { 12 | return { 13 | id: item.id, 14 | text: item.text || "", 15 | by: item.by || "deleted", 16 | time: item.time, 17 | parent: item.parent, 18 | kids: item.kids || [], 19 | type: "comment", 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "esModuleInterop": true, 7 | "outDir": "./dist", 8 | "rootDir": "./src", 9 | "strict": true, 10 | "removeComments": true, 11 | "sourceMap": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "resolveJsonModule": true 14 | }, 15 | "include": [ 16 | "./src/**/*.ts" 17 | ], 18 | "exclude": [ 19 | "node_modules" 20 | ] 21 | } -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | /** 4 | * Validate input against a Zod schema 5 | */ 6 | export function validateInput(schema: z.ZodType, input: unknown): T { 7 | try { 8 | return schema.parse(input); 9 | } catch (error) { 10 | if (error instanceof z.ZodError) { 11 | const issues = error.issues 12 | .map((issue) => `${issue.path.join(".")}: ${issue.message}`) 13 | .join(", "); 14 | throw new Error(`Validation error: ${issues}`); 15 | } 16 | throw error; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | properties: {} 9 | description: No configuration required for the Hacker News MCP Server 10 | commandFunction: 11 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 12 | |- 13 | (config) => ({ 14 | command: 'node', 15 | args: ['dist/index.js'], 16 | env: {} 17 | }) 18 | exampleConfig: {} 19 | -------------------------------------------------------------------------------- /src/models/story.ts: -------------------------------------------------------------------------------- 1 | export interface Story { 2 | id: number; 3 | title: string; 4 | url?: string; 5 | text?: string; 6 | by: string; 7 | score: number; 8 | time: number; 9 | descendants: number; 10 | kids?: number[]; 11 | type: "story"; 12 | } 13 | 14 | export function formatStory(item: any): Story { 15 | return { 16 | id: item.id, 17 | title: item.title, 18 | url: item.url, 19 | text: item.text, 20 | by: item.by, 21 | score: item.score || 0, 22 | time: item.time, 23 | descendants: item.descendants || 0, 24 | kids: item.kids || [], 25 | type: "story", 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | npm-debug.log* 4 | yarn-debug.log* 5 | yarn-error.log* 6 | package-lock.json 7 | yarn.lock 8 | 9 | # Build output 10 | dist/ 11 | build/ 12 | *.tsbuildinfo 13 | 14 | # Environment variables 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | # IDE and editor files 22 | .idea/ 23 | .vscode/ 24 | *.swp 25 | *.swo 26 | .DS_Store 27 | *.sublime-project 28 | *.sublime-workspace 29 | 30 | # Logs 31 | logs/ 32 | *.log 33 | 34 | # Testing 35 | coverage/ 36 | .nyc_output/ 37 | 38 | # Temporary files 39 | tmp/ 40 | temp/ 41 | 42 | # Misc 43 | .cache/ 44 | .npm/ 45 | .eslintcache 46 | .stylelintcache 47 | *.tgz -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Mustapha Abdulhameed Bolaji 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@devabdultech/hn-mcp-server", 3 | "version": "1.2.2", 4 | "main": "./dist/index.js", 5 | "type": "module", 6 | "bin": { 7 | "hackernews-mcp": "./dist/index.js" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/devabdultech/hn-mcp.git" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/devabdultech/hn-mcp/issues" 15 | }, 16 | "files": [ 17 | "dist", 18 | "dist/index.js" 19 | ], 20 | "scripts": { 21 | "build": "tsc && shx chmod +x dist/*.js", 22 | "start": "node dist/index.js" 23 | }, 24 | "keywords": [ 25 | "mcp", 26 | "model-context-protocol", 27 | "hacker-news", 28 | "hn", 29 | "news", 30 | "claude" 31 | ], 32 | "author": "devabdultech", 33 | "license": "MIT", 34 | "description": "MCP Server for using the Hacker News API", 35 | "dependencies": { 36 | "@modelcontextprotocol/sdk": "^1.6.1", 37 | "@types/node": "^22.13.7", 38 | "@types/node-fetch": "^2.6.12", 39 | "node-fetch": "^3.3.2", 40 | "zod": "^3.24.2", 41 | "zod-to-json-schema": "^3.24.3" 42 | }, 43 | "devDependencies": { 44 | "shx": "^0.3.4", 45 | "ts-node": "^10.9.2", 46 | "typescript": "^5.8.2" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // Story schemas 4 | export const StorySchema = z.object({ 5 | id: z.number(), 6 | title: z.string(), 7 | url: z.string().optional(), 8 | text: z.string().optional(), 9 | by: z.string(), 10 | score: z.number(), 11 | time: z.number(), 12 | descendants: z.number(), 13 | kids: z.array(z.number()).optional(), 14 | type: z.literal("story"), 15 | }); 16 | 17 | // Comment schemas 18 | export const CommentSchema = z.object({ 19 | id: z.number(), 20 | text: z.string(), 21 | by: z.string(), 22 | time: z.number(), 23 | parent: z.number(), 24 | kids: z.array(z.number()).optional(), 25 | type: z.literal("comment"), 26 | }); 27 | 28 | // User schemas 29 | export const UserSchema = z.object({ 30 | id: z.string(), 31 | karma: z.number(), 32 | created: z.number(), 33 | about: z.string().optional(), 34 | submitted: z.array(z.number()).optional(), 35 | }); 36 | 37 | // Request schemas 38 | export const SearchParamsSchema = z.object({ 39 | query: z.string(), 40 | type: z.enum(["all", "story", "comment"]).default("all"), 41 | page: z.number().int().min(0).default(0), 42 | hitsPerPage: z.number().int().min(1).max(100).default(20), 43 | }); 44 | 45 | export const StoryRequestSchema = z.object({ 46 | id: z.number().int().positive(), 47 | }); 48 | 49 | export const CommentRequestSchema = z.object({ 50 | id: z.number().int().positive(), 51 | }); 52 | 53 | export const CommentsRequestSchema = z.object({ 54 | storyId: z.number().int().positive(), 55 | limit: z.number().int().min(1).max(100).default(30), 56 | }); 57 | 58 | export const CommentTreeRequestSchema = z.object({ 59 | storyId: z.number().int().positive(), 60 | }); 61 | 62 | export const UserRequestSchema = z.object({ 63 | id: z.string(), 64 | }); 65 | 66 | export const StoriesRequestSchema = z.object({ 67 | type: z.enum(["top", "new", "best", "ask", "show", "job"]), 68 | limit: z.number().int().min(1).max(100).default(30), 69 | }); 70 | -------------------------------------------------------------------------------- /src/api/algolia.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | const API_BASE_URL = "https://hn.algolia.com/api/v1"; 4 | 5 | /** 6 | * Client for the Algolia Hacker News API 7 | */ 8 | export class AlgoliaAPI { 9 | /** 10 | * Search for stories and comments 11 | */ 12 | async search( 13 | query: string, 14 | options: { 15 | tags?: string; 16 | numericFilters?: string; 17 | page?: number; 18 | hitsPerPage?: number; 19 | } = {} 20 | ): Promise { 21 | const params = new URLSearchParams(); 22 | params.append("query", query); 23 | 24 | if (options.tags) params.append("tags", options.tags); 25 | if (options.numericFilters) 26 | params.append("numericFilters", options.numericFilters); 27 | if (options.page !== undefined) 28 | params.append("page", options.page.toString()); 29 | if (options.hitsPerPage !== undefined) 30 | params.append("hitsPerPage", options.hitsPerPage.toString()); 31 | 32 | const url = `${API_BASE_URL}/search?${params.toString()}`; 33 | const response = await fetch(url); 34 | return response.json(); 35 | } 36 | 37 | /** 38 | * Search for stories only 39 | */ 40 | async searchStories( 41 | query: string, 42 | options: { 43 | page?: number; 44 | hitsPerPage?: number; 45 | } = {} 46 | ): Promise { 47 | return this.search(query, { 48 | tags: "story", 49 | ...options, 50 | }); 51 | } 52 | 53 | /** 54 | * Get a story with its comments 55 | */ 56 | async getStoryWithComments(storyId: number): Promise { 57 | const response = await fetch(`${API_BASE_URL}/items/${storyId}`); 58 | return response.json(); 59 | } 60 | 61 | /** 62 | * Get a user profile 63 | */ 64 | async getUser(username: string): Promise { 65 | const response = await fetch(`${API_BASE_URL}/users/${username}`); 66 | return response.json(); 67 | } 68 | } 69 | 70 | // Export a singleton instance 71 | export const algoliaApi = new AlgoliaAPI(); 72 | -------------------------------------------------------------------------------- /src/api/hn.ts: -------------------------------------------------------------------------------- 1 | import fetch from "node-fetch"; 2 | 3 | const API_BASE_URL = "https://hacker-news.firebaseio.com/v0"; 4 | 5 | /** 6 | * Client for the official Hacker News API 7 | */ 8 | export class HackerNewsAPI { 9 | /** 10 | * Fetch an item by ID 11 | */ 12 | async getItem(id: number): Promise { 13 | const response = await fetch(`${API_BASE_URL}/item/${id}.json`); 14 | return response.json(); 15 | } 16 | 17 | /** 18 | * Fetch multiple items by ID 19 | */ 20 | async getItems(ids: number[]): Promise { 21 | return Promise.all(ids.map((id) => this.getItem(id))); 22 | } 23 | 24 | /** 25 | * Fetch top stories 26 | */ 27 | async getTopStories(limit: number = 30): Promise { 28 | const response = await fetch(`${API_BASE_URL}/topstories.json`); 29 | const ids = (await response.json()) as number[]; 30 | return ids.slice(0, limit); 31 | } 32 | 33 | /** 34 | * Fetch new stories 35 | */ 36 | async getNewStories(limit: number = 30): Promise { 37 | const response = await fetch(`${API_BASE_URL}/newstories.json`); 38 | const ids = (await response.json()) as number[]; 39 | return ids.slice(0, limit); 40 | } 41 | 42 | /** 43 | * Fetch best stories 44 | */ 45 | async getBestStories(limit: number = 30): Promise { 46 | const response = await fetch(`${API_BASE_URL}/beststories.json`); 47 | const ids = (await response.json()) as number[]; 48 | return ids.slice(0, limit); 49 | } 50 | 51 | /** 52 | * Fetch ask stories 53 | */ 54 | async getAskStories(limit: number = 30): Promise { 55 | const response = await fetch(`${API_BASE_URL}/askstories.json`); 56 | const ids = (await response.json()) as number[]; 57 | return ids.slice(0, limit); 58 | } 59 | 60 | /** 61 | * Fetch show stories 62 | */ 63 | async getShowStories(limit: number = 30): Promise { 64 | const response = await fetch(`${API_BASE_URL}/showstories.json`); 65 | const ids = (await response.json()) as number[]; 66 | return ids.slice(0, limit); 67 | } 68 | 69 | /** 70 | * Fetch job stories 71 | */ 72 | async getJobStories(limit: number = 30): Promise { 73 | const response = await fetch(`${API_BASE_URL}/jobstories.json`); 74 | const ids = (await response.json()) as number[]; 75 | return ids.slice(0, limit); 76 | } 77 | 78 | /** 79 | * Fetch a user by ID 80 | */ 81 | async getUser(id: string): Promise { 82 | const response = await fetch(`${API_BASE_URL}/user/${id}.json`); 83 | return response.json(); 84 | } 85 | 86 | /** 87 | * Fetch the maximum item ID 88 | */ 89 | async getMaxItemId(): Promise { 90 | const response = await fetch(`${API_BASE_URL}/maxitem.json`); 91 | const result = (await response.json()) as number; 92 | return result; 93 | } 94 | } 95 | 96 | // Export a singleton instance 97 | export const hnApi = new HackerNewsAPI(); 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker News MCP Server 2 | 3 | [![smithery badge](https://smithery.ai/badge/@devabdultech/hn-mcp)](https://smithery.ai/server/@devabdultech/hn-mcp) 4 | Official Hacker News MCP Server - Adds powerful Hacker News integration to Cursor, Claude, and any other LLM clients. Access stories, comments, user profiles, and search functionality through the Model Context Protocol. 5 | 6 | 7 | Hacker News Server MCP server 8 | 9 | 10 | ## Features 11 | 12 | - Search stories and comments using Algolia's HN Search API 13 | - Get stories by type (top, new, best, ask, show, job) 14 | - Get individual stories with comments 15 | - Get comment trees and user discussions 16 | - Get user profiles and submissions 17 | - Real-time access to Hacker News data 18 | 19 | ## Set Up 20 | 21 | ### Running on Claude Desktop 22 | 23 | Add this to your `claude_desktop_config.json`: 24 | 25 | ```json 26 | { 27 | "mcpServers": { 28 | "hackernews": { 29 | "command": "npx", 30 | "args": ["-y", "@devabdultech/hn-mcp-server"] 31 | } 32 | } 33 | } 34 | ``` 35 | 36 | ### Installing via Smithery 37 | 38 | To install Hacker News MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@devabdultech/hn-mcp): 39 | 40 | ```bash 41 | npx -y @smithery/cli install @devabdultech/hn-mcp --client claude 42 | ``` 43 | 44 | ## Tools 45 | 46 | 1. `search` 47 | * Search for stories and comments on Hacker News using Algolia's search API 48 | * Inputs: 49 | * `query` (string): Search query 50 | * `type` (optional string): Filter by type ('story' or 'comment') 51 | * `page` (optional number): Page number for pagination 52 | * `hitsPerPage` (optional number): Results per page (max 100) 53 | * Returns: Search results with stories and comments 54 | 55 | 2. `getStories` 56 | * Get multiple stories by type (top, new, best, ask, show, job) 57 | * Inputs: 58 | * `type` (string): Type of stories to fetch ('top', 'new', 'best', 'ask', 'show', 'job') 59 | * `limit` (optional number): Number of stories to fetch (max 100) 60 | * Returns: Array of story objects 61 | 62 | 3. `getStoryWithComments` 63 | * Get a story along with its comment thread 64 | * Inputs: 65 | * `id` (number): Story ID 66 | * Returns: Story details with nested comments 67 | 68 | 4. `getCommentTree` 69 | * Get the full comment tree for a story 70 | * Inputs: 71 | * `storyId` (number): ID of the story 72 | * Returns: Hierarchical comment tree structure 73 | 74 | 5. `getUser` 75 | * Get a user's profile information 76 | * Inputs: 77 | * `id` (string): Username 78 | * Returns: User profile details including karma, created date, and about text 79 | 80 | 6. `getUserSubmissions` 81 | * Get a user's submissions (stories and comments) 82 | * Inputs: 83 | * `id` (string): Username 84 | * Returns: Array of user's submitted stories and comments 85 | 86 | 87 | ### Contributing 88 | 89 | 1. Fork the repository 90 | 2. Create your feature branch 91 | 3. Commit your changes 92 | 4. Push to the branch 93 | 5. Create a new Pull Request 94 | 95 | ## License 96 | 97 | This MCP server is licensed under the MIT License. See the LICENSE file for details. 98 | 99 | ## About 100 | 101 | This MCP server is built and maintained by [devabdultech](https://github.com/devabdultech). It uses the official Hacker News API and Algolia Search API to provide comprehensive access to Hacker News data through the Model Context Protocol. 102 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { 6 | CallToolRequestSchema, 7 | ListToolsRequestSchema, 8 | ErrorCode, 9 | McpError, 10 | } from "@modelcontextprotocol/sdk/types.js"; 11 | import { hnApi } from "./api/hn.js"; 12 | import { algoliaApi } from "./api/algolia.js"; 13 | import { formatStory } from "./models/story.js"; 14 | import { formatComment } from "./models/comment.js"; 15 | import { formatUser } from "./models/user.js"; 16 | import { validateInput } from "./utils/validation.js"; 17 | import { 18 | SearchParamsSchema, 19 | CommentRequestSchema, 20 | CommentsRequestSchema, 21 | CommentTreeRequestSchema, 22 | UserRequestSchema, 23 | } from "./schemas/index.js"; 24 | 25 | // Create the MCP server 26 | const server = new Server( 27 | { 28 | name: "hackernews-mcp-server", 29 | version: "1.2.2", 30 | }, 31 | { 32 | capabilities: { 33 | tools: {}, 34 | }, 35 | } 36 | ); 37 | 38 | // Set up the ListTools request handler 39 | server.setRequestHandler(ListToolsRequestSchema, async () => { 40 | return { 41 | tools: [ 42 | { 43 | name: "search", 44 | description: "Search for stories and comments on Hacker News", 45 | inputSchema: { 46 | type: "object", 47 | properties: { 48 | query: { type: "string", description: "The search query" }, 49 | type: { 50 | type: "string", 51 | enum: ["all", "story", "comment"], 52 | description: "The type of content to search for", 53 | default: "all", 54 | }, 55 | page: { 56 | type: "number", 57 | description: "The page number", 58 | default: 0, 59 | }, 60 | hitsPerPage: { 61 | type: "number", 62 | description: "The number of results per page", 63 | default: 20, 64 | }, 65 | }, 66 | required: ["query"], 67 | }, 68 | }, 69 | { 70 | name: "getStory", 71 | description: "Get a single story by ID", 72 | inputSchema: { 73 | type: "object", 74 | properties: { 75 | id: { type: "number", description: "The ID of the story" }, 76 | }, 77 | required: ["id"], 78 | }, 79 | }, 80 | { 81 | name: "getStoryWithComments", 82 | description: "Get a story with its comments", 83 | inputSchema: { 84 | type: "object", 85 | properties: { 86 | id: { type: "number", description: "The ID of the story" }, 87 | }, 88 | required: ["id"], 89 | }, 90 | }, 91 | { 92 | name: "getStories", 93 | description: 94 | "Get multiple stories by type (top, new, best, ask, show, job)", 95 | inputSchema: { 96 | type: "object", 97 | properties: { 98 | type: { 99 | type: "string", 100 | enum: ["top", "new", "best", "ask", "show", "job"], 101 | description: "The type of stories to fetch", 102 | }, 103 | limit: { 104 | type: "number", 105 | description: "The maximum number of stories to fetch", 106 | default: 30, 107 | }, 108 | }, 109 | required: ["type"], 110 | }, 111 | }, 112 | { 113 | name: "getComment", 114 | description: "Get a single comment by ID", 115 | inputSchema: { 116 | type: "object", 117 | properties: { 118 | id: { type: "number", description: "The ID of the comment" }, 119 | }, 120 | required: ["id"], 121 | }, 122 | }, 123 | { 124 | name: "getComments", 125 | description: "Get comments for a story", 126 | inputSchema: { 127 | type: "object", 128 | properties: { 129 | storyId: { type: "number", description: "The ID of the story" }, 130 | limit: { 131 | type: "number", 132 | description: "The maximum number of comments to fetch", 133 | default: 30, 134 | }, 135 | }, 136 | required: ["storyId"], 137 | }, 138 | }, 139 | { 140 | name: "getCommentTree", 141 | description: "Get a comment tree for a story", 142 | inputSchema: { 143 | type: "object", 144 | properties: { 145 | storyId: { type: "number", description: "The ID of the story" }, 146 | }, 147 | required: ["storyId"], 148 | }, 149 | }, 150 | { 151 | name: "getUser", 152 | description: "Get a user profile by ID", 153 | inputSchema: { 154 | type: "object", 155 | properties: { 156 | id: { type: "string", description: "The ID of the user" }, 157 | }, 158 | required: ["id"], 159 | }, 160 | }, 161 | { 162 | name: "getUserSubmissions", 163 | description: "Get a user's submissions", 164 | inputSchema: { 165 | type: "object", 166 | properties: { 167 | id: { type: "string", description: "The ID of the user" }, 168 | }, 169 | required: ["id"], 170 | }, 171 | }, 172 | ], 173 | }; 174 | }); 175 | 176 | // Set up the CallTool request handler 177 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 178 | const { name, arguments: args } = request.params; 179 | 180 | switch (name) { 181 | case "search": { 182 | const validatedArgs = validateInput(SearchParamsSchema, args); 183 | const { query, type, page, hitsPerPage } = validatedArgs; 184 | const tags = type === "all" ? undefined : type; 185 | const results = await algoliaApi.search(query, { 186 | tags, 187 | page, 188 | hitsPerPage, 189 | }); 190 | 191 | const hits = results.hits || []; 192 | const text = hits 193 | .map( 194 | (hit: any, index: number) => 195 | `${index + 1}. ${hit.title}\n` + 196 | ` ID: ${hit.objectID}\n` + 197 | ` URL: ${hit.url || "(text post)"}\n` + 198 | ` Points: ${hit.points} | Author: ${hit.author} | Comments: ${hit.num_comments}\n\n` 199 | ) 200 | .join(""); 201 | 202 | return { 203 | content: [{ type: "text", text: text.trim() }], 204 | }; 205 | } 206 | 207 | case "getStory": { 208 | const { id } = args as { id: number }; 209 | const item = await hnApi.getItem(id); 210 | if (!item || item.type !== "story") { 211 | throw new McpError( 212 | ErrorCode.InvalidParams, 213 | `Story with ID ${id} not found` 214 | ); 215 | } 216 | const story = formatStory(item); 217 | const text = 218 | `Story ID: ${story.id}\n` + 219 | `Title: ${story.title}\n` + 220 | `URL: ${story.url || "(text post)"}\n` + 221 | `Points: ${story.score} | Author: ${story.by} | Comments: ${story.descendants}\n` + 222 | (story.text ? `\nContent:\n${story.text}\n` : ""); 223 | 224 | return { 225 | content: [{ type: "text", text: text.trim() }], 226 | }; 227 | } 228 | 229 | case "getStoryWithComments": { 230 | const { id } = args as { id: number }; 231 | try { 232 | const data = await algoliaApi.getStoryWithComments(id); 233 | if (!data || !data.title) { 234 | throw new McpError( 235 | ErrorCode.InvalidParams, 236 | `Story with ID ${id} not found` 237 | ); 238 | } 239 | 240 | const formatCommentTree = (comment: any, depth = 0): string => { 241 | const indent = " ".repeat(depth); 242 | let text = `${indent}Comment by ${comment.author} (ID: ${comment.id}):\n`; 243 | text += `${indent}${comment.text}\n`; 244 | text += `${indent}Posted: ${comment.created_at}\n\n`; 245 | 246 | if (comment.children) { 247 | text += comment.children 248 | .map((child: any) => formatCommentTree(child, depth + 1)) 249 | .join(""); 250 | } 251 | return text; 252 | }; 253 | 254 | const text = 255 | `Story ID: ${data.id}\n` + 256 | `Title: ${data.title}\n` + 257 | `URL: ${data.url || "(text post)"}\n` + 258 | `Points: ${data.points} | Author: ${data.author}\n\n` + 259 | `Comments:\n` + 260 | (data.children || []) 261 | .map((comment: any) => formatCommentTree(comment)) 262 | .join(""); 263 | 264 | return { 265 | content: [{ type: "text", text: text.trim() }], 266 | }; 267 | } catch (err) { 268 | const error = err as Error; 269 | throw new McpError( 270 | ErrorCode.InternalError, 271 | `Failed to fetch story: ${error.message}` 272 | ); 273 | } 274 | } 275 | 276 | case "getStories": { 277 | const { type, limit = 30 } = args as { 278 | type: "top" | "new" | "best" | "ask" | "show" | "job"; 279 | limit?: number; 280 | }; 281 | try { 282 | let storyIds: number[] = []; 283 | 284 | switch (type) { 285 | case "top": 286 | storyIds = await hnApi.getTopStories(limit); 287 | break; 288 | case "new": 289 | storyIds = await hnApi.getNewStories(limit); 290 | break; 291 | case "best": 292 | storyIds = await hnApi.getBestStories(limit); 293 | break; 294 | case "ask": 295 | storyIds = await hnApi.getAskStories(limit); 296 | break; 297 | case "show": 298 | storyIds = await hnApi.getShowStories(limit); 299 | break; 300 | case "job": 301 | storyIds = await hnApi.getJobStories(limit); 302 | break; 303 | } 304 | 305 | const items = await hnApi.getItems(storyIds); 306 | const stories = items 307 | .filter((item) => item && item.type === "story") 308 | .map(formatStory); 309 | 310 | if (stories.length === 0) { 311 | return { 312 | content: [{ type: "text", text: "No stories found." }], 313 | }; 314 | } 315 | 316 | const text = stories 317 | .map( 318 | (story, index) => 319 | `${index + 1}. ${story.title}\n` + 320 | ` ID: ${story.id}\n` + 321 | ` URL: ${story.url || "(text post)"}\n` + 322 | ` Points: ${story.score} | Author: ${story.by} | Comments: ${story.descendants}\n\n` 323 | ) 324 | .join(""); 325 | 326 | return { 327 | content: [{ type: "text", text: text.trim() }], 328 | }; 329 | } catch (err) { 330 | const error = err as Error; 331 | throw new McpError( 332 | ErrorCode.InternalError, 333 | `Failed to fetch stories: ${error.message}` 334 | ); 335 | } 336 | } 337 | 338 | case "getComment": { 339 | const validatedArgs = validateInput(CommentRequestSchema, args); 340 | const { id } = validatedArgs; 341 | const item = await hnApi.getItem(id); 342 | if (!item || item.type !== "comment") { 343 | throw new McpError( 344 | ErrorCode.InvalidParams, 345 | `Comment with ID ${id} not found` 346 | ); 347 | } 348 | const comment = formatComment(item); 349 | const text = 350 | `Comment ID: ${comment.id}\n` + 351 | `Comment by ${comment.by}:\n` + 352 | `${comment.text}\n` + 353 | `Parent ID: ${comment.parent}\n`; 354 | 355 | return { 356 | content: [{ type: "text", text: text.trim() }], 357 | }; 358 | } 359 | 360 | case "getComments": { 361 | const validatedArgs = validateInput(CommentsRequestSchema, args); 362 | const { storyId, limit = 30 } = validatedArgs; 363 | try { 364 | const story = await hnApi.getItem(storyId); 365 | 366 | if (!story || !story.kids || story.kids.length === 0) { 367 | return { 368 | content: [ 369 | { 370 | type: "text", 371 | text: `No comments found for story ID: ${storyId}`, 372 | }, 373 | ], 374 | }; 375 | } 376 | 377 | const commentIds = story.kids.slice(0, limit); 378 | const comments = await hnApi.getItems(commentIds); 379 | const formattedComments = comments 380 | .filter((item) => item && item.type === "comment") 381 | .map(formatComment); 382 | 383 | if (formattedComments.length === 0) { 384 | return { 385 | content: [ 386 | { 387 | type: "text", 388 | text: `No comments found for story ID: ${storyId}`, 389 | }, 390 | ], 391 | }; 392 | } 393 | 394 | const text = 395 | `Comments for Story ID: ${storyId}\n\n` + 396 | formattedComments 397 | .map( 398 | (comment, index) => 399 | `${index + 1}. Comment by ${comment.by} (ID: ${ 400 | comment.id 401 | }):\n` + ` ${comment.text}\n\n` 402 | ) 403 | .join(""); 404 | 405 | return { 406 | content: [{ type: "text", text: text.trim() }], 407 | }; 408 | } catch (err) { 409 | const error = err as Error; 410 | throw new McpError( 411 | ErrorCode.InternalError, 412 | `Failed to fetch comments: ${error.message}` 413 | ); 414 | } 415 | } 416 | 417 | case "getCommentTree": { 418 | const validatedArgs = validateInput(CommentTreeRequestSchema, args); 419 | const { storyId } = validatedArgs; 420 | try { 421 | const data = await algoliaApi.getStoryWithComments(storyId); 422 | 423 | if (!data || !data.children || data.children.length === 0) { 424 | return { 425 | content: [ 426 | { 427 | type: "text", 428 | text: `No comments found for story ID: ${storyId}`, 429 | }, 430 | ], 431 | }; 432 | } 433 | 434 | const formatCommentTree = (comment: any, depth = 0): string => { 435 | const indent = " ".repeat(depth); 436 | let text = `${indent}Comment by ${comment.author} (ID: ${comment.id}):\n`; 437 | text += `${indent}${comment.text}\n\n`; 438 | 439 | if (comment.children) { 440 | text += comment.children 441 | .map((child: any) => formatCommentTree(child, depth + 1)) 442 | .join(""); 443 | } 444 | return text; 445 | }; 446 | 447 | const text = 448 | `Comment tree for Story ID: ${storyId}\n\n` + 449 | data.children 450 | .map((comment: any) => formatCommentTree(comment)) 451 | .join(""); 452 | 453 | return { 454 | content: [{ type: "text", text: text.trim() }], 455 | }; 456 | } catch (err) { 457 | const error = err as Error; 458 | throw new McpError( 459 | ErrorCode.InternalError, 460 | `Failed to fetch comment tree: ${error.message}` 461 | ); 462 | } 463 | } 464 | 465 | case "getUser": { 466 | const validatedArgs = validateInput(UserRequestSchema, args); 467 | const { id } = validatedArgs; 468 | 469 | const user = await hnApi.getUser(id); 470 | 471 | if (!user) { 472 | throw new McpError( 473 | ErrorCode.InvalidParams, 474 | `User with ID ${id} not found` 475 | ); 476 | } 477 | 478 | const formattedUser = formatUser(user); 479 | const text = 480 | `User Profile:\n` + 481 | `Username: ${formattedUser.id}\n` + 482 | `Karma: ${formattedUser.karma}\n` + 483 | `Created: ${new Date(formattedUser.created * 1000).toISOString()}\n` + 484 | (formattedUser.about ? `\nAbout:\n${formattedUser.about}\n` : ""); 485 | 486 | return { 487 | content: [{ type: "text", text: text.trim() }], 488 | }; 489 | } 490 | 491 | case "getUserSubmissions": { 492 | const validatedArgs = validateInput(UserRequestSchema, args); 493 | const { id } = validatedArgs; 494 | 495 | const results = await algoliaApi.search("", { 496 | tags: `author_${id}`, 497 | hitsPerPage: 50, 498 | }); 499 | 500 | const hits = results.hits || []; 501 | const text = 502 | `Submissions by ${id}:\n\n` + 503 | hits 504 | .map( 505 | (hit: any, index: number) => 506 | `${index + 1}. ${hit.title || hit.comment_text}\n` + 507 | ` ID: ${hit.objectID}\n` + 508 | ` Points: ${hit.points || 0} | Posted: ${hit.created_at}\n\n` 509 | ) 510 | .join(""); 511 | 512 | return { 513 | content: [{ type: "text", text: text.trim() }], 514 | }; 515 | } 516 | 517 | default: 518 | throw new McpError(ErrorCode.MethodNotFound, `Tool '${name}' not found`); 519 | } 520 | }); 521 | 522 | // Connect to the transport 523 | async function runServer() { 524 | try { 525 | console.error("Initializing server..."); 526 | const transport = new StdioServerTransport(); 527 | 528 | // Connect transport 529 | await server.connect(transport); 530 | console.error("Server started and connected successfully"); 531 | 532 | // Handle process signals 533 | process.on("SIGINT", () => { 534 | console.error("Received SIGINT, shutting down..."); 535 | transport.close(); 536 | process.exit(0); 537 | }); 538 | 539 | process.on("SIGTERM", () => { 540 | console.error("Received SIGTERM, shutting down..."); 541 | transport.close(); 542 | process.exit(0); 543 | }); 544 | 545 | console.error("Hacker News MCP Server running on stdio"); 546 | } catch (error: unknown) { 547 | console.error("Error starting server:", error); 548 | process.exit(1); 549 | } 550 | } 551 | 552 | runServer().catch((error: unknown) => { 553 | console.error("Fatal error in main():", error); 554 | process.exit(1); 555 | }); 556 | --------------------------------------------------------------------------------