/**
* MCP Tool implementations for Hacker News
*/
import { z } from 'zod';
import { HackerNewsAPI } from '../services/hn-api.js';
export const browseStoriesSchema = z.object({
type: z
.enum(['top', 'new', 'best', 'ask', 'show', 'job'])
.optional()
.default('top')
.describe('Story type: "top" (highest voted), "new" (most recent), "best" (curated best), "ask" (Ask HN), "show" (Show HN), "job" (Who is hiring)'),
limit: z
.number()
.min(1)
.max(100)
.optional()
.default(30)
.describe('Default 30, range (1-100). Change ONLY IF user specifies.'),
});
export const searchHNSchema = z.object({
query: z.string().describe('Search terms (e.g., "rust async", "GPT-4", "startup advice")'),
tags: z
.enum(['story', 'comment', 'ask_hn', 'show_hn', 'poll'])
.optional()
.describe('Filter by: "story" (submissions), "comment" (discussions), "ask_hn", "show_hn", or "poll"'),
dateRange: z
.enum(['all', 'last24h', 'pastWeek', 'pastMonth', 'pastYear'])
.optional()
.default('all')
.describe('Time filter: "all" (any time), "last24h", "pastWeek", "pastMonth", or "pastYear"'),
sortBy: z
.enum(['relevance', 'date'])
.optional()
.default('relevance')
.describe('Sort results by relevance score or date posted'),
limit: z
.number()
.min(1)
.max(100)
.optional()
.default(30)
.describe('Default 30, range (1-100). Override ONLY IF user requests.'),
});
export const getStoryDetailsSchema = z.object({
id: z
.number()
.or(z.string().transform((val) => parseInt(val, 10)))
.describe('Story ID from HN URL (e.g., 38765432 from item?id=38765432)'),
maxComments: z
.number()
.min(1)
.max(50)
.optional()
.default(10)
.describe('Default 10, range (1-50). Change ONLY IF user specifies.'),
commentDepth: z
.number()
.min(1)
.max(5)
.optional()
.default(3)
.describe('Default 3, range (1-5). Change ONLY IF user asks.'),
});
export const userAnalysisSchema = z.object({
username: z.string().describe('HN username (e.g., "pg", "dang", "tptacek")'),
submissionLimit: z
.number()
.min(0)
.max(50)
.optional()
.default(10)
.describe('Default 10, range (0-50). Override ONLY IF user requests.'),
});
export const hnExplainSchema = z.object({
term: z
.string()
.describe('HN term or concept (e.g., "karma", "flagged", "dupe", "Show HN", "Ask HN")'),
});
/**
* Tool implementations
*/
export class HackerNewsTools {
constructor(private api: HackerNewsAPI) {}
async browseStories(params: z.infer<typeof browseStoriesSchema>) {
const stories = await this.api.getStories(params.type, params.limit);
return {
stories: stories.map((story) => ({
id: story.id,
title: story.title,
author: story.author,
score: story.score,
time_ago: story.time_ago,
url: story.url,
text: story.text ? story.text.substring(0, 500) + (story.text.length > 500 ? '...' : '') : undefined,
num_comments: story.num_comments,
hn_url: story.hn_url,
type: story.type,
})),
count: stories.length,
};
}
async searchHN(params: z.infer<typeof searchHNSchema>) {
let numericFilters = '';
const now = Math.floor(Date.now() / 1000);
switch (params.dateRange) {
case 'last24h':
numericFilters = `created_at_i>${now - 86400}`;
break;
case 'pastWeek':
numericFilters = `created_at_i>${now - 604800}`;
break;
case 'pastMonth':
numericFilters = `created_at_i>${now - 2592000}`;
break;
case 'pastYear':
numericFilters = `created_at_i>${now - 31536000}`;
break;
}
const searchOptions: any = {
hitsPerPage: params.limit,
page: 0,
};
if (params.tags) {
searchOptions.tags = params.tags;
}
if (numericFilters) {
searchOptions.numericFilters = numericFilters;
}
const results = await this.api.search(params.query, searchOptions);
const processedHits = results.hits.map((hit) => ({
id: hit.objectID,
title: hit.title || hit.story_title || '',
author: hit.author,
points: hit.points,
created_at: hit.created_at,
url: hit.url || hit.story_url,
text: (() => {
const content = hit.comment_text || hit.story_text;
if (!content) return undefined;
return content.length > 500 ? content.substring(0, 500) + '...' : content;
})(),
num_comments: hit.num_comments,
type: hit._tags.includes('story') ? 'story' : 'comment',
hn_url: hit._tags.includes('story')
? `https://news.ycombinator.com/item?id=${hit.objectID}`
: `https://news.ycombinator.com/item?id=${hit.story_id}`,
}));
return {
results: processedHits,
total_hits: results.nbHits > 1000 ? '1000+' : results.nbHits,
count: processedHits.length,
};
}
async getStoryDetails(params: z.infer<typeof getStoryDetailsSchema>) {
const id = typeof params.id === 'string' ? parseInt(params.id, 10) : params.id;
const result = await this.api.getStoryWithComments(
id,
params.maxComments,
params.commentDepth
);
return {
story: result.story,
comments: result.comments,
};
}
async userAnalysis(params: z.infer<typeof userAnalysisSchema>) {
const user = await this.api.getUser(params.username);
if (!user) {
throw new Error(`User ${params.username} not found`);
}
const result: any = {
user: {
id: user.id,
karma: user.karma,
created: user.created,
created_ago: this.timeAgo(user.created),
about: user.about,
},
};
if (user.submitted) {
const submissions = await this.api.getUserItems(
params.username,
params.submissionLimit
);
result.recent_submissions = submissions;
result.total_submissions = user.submitted.length;
}
return result;
}
async hnExplain(params: z.infer<typeof hnExplainSchema>) {
const explanations: Record<string, any> = {
karma: {
term: 'karma',
definition: 'Points earned from upvotes on stories and comments',
explanation: 'Karma is the cumulative score from all your submissions. Stories earn 1 karma per upvote, comments earn 1 karma per upvote. There is no negative karma from downvotes.',
usage: 'Higher karma allows posting more frequently and indicates community trust.',
},
flagged: {
term: 'flagged',
definition: 'Content marked by users as inappropriate or off-topic',
explanation: 'Users with sufficient karma can flag stories and comments they believe violate HN guidelines. Heavily flagged content may be removed.',
usage: 'Flag sparingly for spam, off-topic content, or guideline violations.',
},
'show hn': {
term: 'Show HN',
definition: 'Posts sharing something you\'ve made',
explanation: 'Show HN posts are for sharing your own projects. The community is generally supportive but expects genuine engagement from the creator.',
usage: 'Title format: "Show HN: [Your Project]". Be ready to answer questions.',
},
'ask hn': {
term: 'Ask HN',
definition: 'Posts asking the community a question',
explanation: 'Ask HN posts are for questions to the community. Can be technical, career-related, or seeking recommendations.',
usage: 'Title format: "Ask HN: [Your Question]". Be specific and clear.',
},
dead: {
term: 'dead',
definition: 'Content killed by moderators or software',
explanation: 'Dead posts/comments are hidden from normal view. Can result from flags, spam detection, or moderator action.',
usage: 'Users with showdead enabled in settings can still view dead content.',
},
dupe: {
term: 'dupe',
definition: 'Duplicate submission of existing content',
explanation: 'HN discourages reposting the same URL. The system often redirects dupes to the original discussion.',
usage: 'Search before submitting. Recent dupes within ~1 year are usually redirected.',
},
vouch: {
term: 'vouch',
definition: 'Endorsing a flagged post to restore visibility',
explanation: 'High-karma users can vouch for flagged content they believe was incorrectly flagged, potentially restoring it.',
usage: 'Use vouching to rescue quality content that was mistakenly flagged.',
},
flame: {
term: 'flame',
definition: 'Hostile or inflammatory comments',
explanation: 'Flaming violates HN guidelines. The community values thoughtful, substantive discussion over heated arguments.',
usage: 'Avoid personal attacks. Assume good faith. Be kind.',
},
};
const termLower = params.term.toLowerCase();
const explanation = explanations[termLower];
if (!explanation) {
return {
term: params.term,
definition: 'Term not found in database',
suggestion: 'This term might be specific to a discussion or not commonly used on HN.',
common_terms: Object.keys(explanations),
};
}
return explanation;
}
private timeAgo(timestamp: number): string {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`;
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`;
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`;
return 'just now';
}
}