# Research: HackerNews MCP Server
**Feature**: 001-hackernews-mcp-server
**Date**: 2025-10-12
**Status**: Completed
## Research Tasks
### 1. MCP TypeScript SDK Best Practices
**Decision**: Use `@modelcontextprotocol/sdk` with `McpServer` high-level API
**Rationale**:
- Official MCP SDK provides high-level `McpServer` class that abstracts protocol details
- Built-in support for tool registration with Zod schema validation
- Automatic JSON-RPC message handling over stdio transport
- Examples from MCP SDK show clear patterns for tool organization
- SDK handles MCP protocol versioning and capability negotiation
**Alternatives Considered**:
- **Low-level `Server` API**: More control but requires manual request handler setup. Rejected because high-level API provides sufficient flexibility while reducing boilerplate.
- **Custom MCP implementation**: Build from scratch following spec. Rejected because official SDK is battle-tested and maintained.
**Implementation Notes**:
- Use `registerTool()` method with Zod schemas for input/output validation
- Structure: one tool per file, each exports a registration function
- Stdio transport via `StdioServerTransport` for Claude Desktop integration
- Tool schemas automatically exposed via MCP protocol
**Code Pattern** (from Context7 MCP SDK docs):
```typescript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({
name: 'hn-mcp-server',
version: '1.0.0'
});
server.registerTool(
'search_stories',
{
title: 'Search Stories',
description: 'Search HN stories by query',
inputSchema: { query: z.string(), tags: z.string().optional() },
outputSchema: { hits: z.array(z.any()), nbHits: z.number() }
},
async ({ query, tags }) => {
// Implementation
}
);
const transport = new StdioServerTransport();
await server.connect(transport);
```
---
### 2. HN Algolia API Client Design
**Decision**: Use Node.js built-in `fetch` with custom wrapper class
**Rationale**:
- Node.js 18+ includes native `fetch` API (no external HTTP client needed)
- HN Algolia API is simple REST API - no need for complex HTTP client
- Custom wrapper allows rate limit tracking and error handling in one place
- Reduces dependencies (constitution principle: prefer existing solutions)
**Alternatives Considered**:
- **axios**: Popular but adds unnecessary dependency. Rejected because native fetch is sufficient.
- **got/ky**: Modern alternatives but overkill for simple GET requests. Rejected for simplicity.
- **Official Algolia client**: HN Algolia API is simpler than full Algolia API. Rejected because it adds weight.
**Implementation Notes**:
- Create `HNClient` class wrapping fetch
- Track rate limit consumption (10,000 req/hr limit)
- Implement exponential backoff for retries
- Type-safe response parsing with TypeScript interfaces
**API Endpoints** (from HN Algolia API docs):
```typescript
// Search endpoints
GET https://hn.algolia.com/api/v1/search?query={query}&tags={tags}
GET https://hn.algolia.com/api/v1/search_by_date?query={query}&tags={tags}
// Item endpoint
GET https://hn.algolia.com/api/v1/items/{id}
// User endpoint
GET https://hn.algolia.com/api/v1/users/{username}
// Query parameters:
// - query: search text
// - tags: story|comment|poll|pollopt|show_hn|ask_hn|front_page|author_{username}|story_{id}
// - numericFilters: created_at_i>X, points>=Y, num_comments>=Z
// - page: page number (0-indexed)
// - hitsPerPage: results per page (default 20)
```
---
### 3. Rate Limiting Strategy
**Decision**: Client-side rate limit tracking with in-memory counter
**Rationale**:
- HN Algolia enforces 10,000 req/hr per IP address
- MCP server is single-tenant (one IP), so simple counter suffices
- Reset counter hourly via setTimeout
- Fail-fast when approaching limit (warn at 90%, error at 95%)
**Alternatives Considered**:
- **bottleneck/p-queue**: Rate limiting libraries. Rejected because requirements are simple (just count requests).
- **Redis-based tracking**: Distributed rate limiting. Rejected because server is single-instance.
- **No rate limiting**: Rely on API errors. Rejected because could lead to IP blacklisting.
**Implementation Notes**:
```typescript
class RateLimiter {
private requests = 0;
private readonly limit = 10000;
private resetTimer: NodeJS.Timeout;
constructor() {
this.resetTimer = setInterval(() => {
this.requests = 0;
}, 60 * 60 * 1000); // Reset hourly
}
async checkLimit(): Promise<void> {
if (this.requests >= this.limit * 0.95) {
throw new Error('Rate limit exceeded (95% of 10k/hr)');
}
if (this.requests >= this.limit * 0.90) {
console.warn('Approaching rate limit (90% of 10k/hr)');
}
this.requests++;
}
}
```
---
### 4. Error Handling Patterns
**Decision**: Custom error types with MCP-friendly error responses
**Rationale**:
- MCP protocol expects specific error format in tool responses
- Different error types require different user guidance
- Structured errors enable better observability
**Error Categories**:
1. **APIError**: HN API returned error response (404, 500, etc.)
2. **RateLimitError**: Rate limit exceeded
3. **ValidationError**: Invalid input parameters
4. **NetworkError**: Request timeout or connection failure
**Implementation Pattern**:
```typescript
class HNError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = 'HNError';
}
toMCPError() {
return {
content: [{
type: 'text',
text: `Error: ${this.message}\nCode: ${this.code}\nContext: ${JSON.stringify(this.context)}`
}],
isError: true
};
}
}
// Usage in tools
try {
const result = await hnClient.search(query);
return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };
} catch (error) {
if (error instanceof HNError) {
return error.toMCPError();
}
throw error; // Let MCP SDK handle unexpected errors
}
```
---
### 5. Structured Logging Approach
**Decision**: Use `pino` for JSON structured logging
**Rationale**:
- Fast, low-overhead logging library
- JSON output by default (constitution requirement)
- Child loggers support correlation IDs
- Widely used in Node.js ecosystem
**Alternatives Considered**:
- **winston**: More features but slower. Rejected for performance.
- **console.log**: Simple but unstructured. Rejected because constitution requires structured logging.
- **Custom logger**: Reinventing wheel. Rejected per constitution (prefer existing solutions).
**Implementation Pattern**:
```typescript
import pino from 'pino';
const logger = pino({
level: process.env.DEBUG ? 'debug' : 'info',
formatters: {
level: (label) => ({ level: label })
}
});
// In tool handlers
const toolLogger = logger.child({
tool: 'search_stories',
correlationId: crypto.randomUUID()
});
toolLogger.info({ query, tags }, 'Executing search');
```
---
### 6. Testing Strategy
**Decision**: Vitest for all tests with separate unit/integration suites
**Rationale**:
- Vitest is fast, modern, and compatible with TypeScript
- Built-in coverage reporting
- Easy mocking for unit tests
- Can run integration tests against live API with controlled concurrency
**Test Organization**:
```
tests/
├── unit/ # Fast, mocked tests
│ ├── tools/ # Test tool logic with mocked HN client
│ └── lib/ # Test utilities (rate limiter, logger, etc.)
├── integration/ # Slow, real API tests
│ ├── tools/ # Test tools against live HN API
│ └── setup.ts # Rate-limited test runner
└── contract/ # MCP protocol compliance
└── schemas.test.ts # Verify tool schemas
```
**Integration Test Approach**:
- Run in serial to respect rate limits
- Use real HN API but with known stable queries
- Cache responses for reliability (optional)
- Skip in CI if rate limit concerns (or use separate API key)
---
### 7. Tool Organization Pattern
**Decision**: One tool per file with factory function export
**Rationale**:
- Each tool is independently testable
- Easy to add/remove tools without affecting others
- Clear file structure for contributors
- Follows MCP SDK examples
**File Pattern**:
```typescript
// src/tools/search-stories.ts
import { z } from 'zod';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { HNClient } from '../lib/hn-client.js';
export function registerSearchStories(server: McpServer, client: HNClient) {
server.registerTool(
'search_stories',
{
title: 'Search Stories',
description: 'Search HN stories by relevance',
inputSchema: {
query: z.string().describe('Search query'),
tags: z.string().optional().describe('Filter tags'),
page: z.number().optional().describe('Page number')
},
outputSchema: {
hits: z.array(z.any()),
nbHits: z.number(),
nbPages: z.number()
}
},
async ({ query, tags, page = 0 }) => {
// Implementation
}
);
}
```
---
### 8. MCP Tool Naming Convention
**Decision**: Use snake_case verb_noun pattern (MCP convention)
**Rationale**:
- Follows MCP SDK examples (get_time, read_file, etc.)
- Clear action-oriented naming
- Consistent with LLM tool calling patterns
**Tool Names**:
- `search_stories` - Search stories by relevance
- `search_by_date` - Search stories/comments by date
- `get_story` - Retrieve specific story
- `get_user` - Retrieve user profile
- `get_front_page` - Get front page stories
- `get_latest_stories` - Get latest submissions
- `get_ask_hn` - Get Ask HN posts
- `get_show_hn` - Get Show HN posts
- `search_comments` - Search comments
---
## Technology Stack Summary
| Component | Technology | Version | Justification |
|-----------|-----------|---------|---------------|
| Language | TypeScript | 5.7+ | Type safety, MCP SDK compatibility |
| Runtime | Node.js | 20 LTS | Stable, long-term support |
| MCP SDK | @modelcontextprotocol/sdk | latest | Official implementation |
| Validation | Zod | latest | MCP SDK standard, type-safe schemas |
| HTTP Client | Node.js fetch | built-in | No extra deps, sufficient for needs |
| Logging | pino | latest | Fast, structured JSON logging |
| Testing | Vitest | latest | Fast, modern, TypeScript support |
| Linting | Biome | latest | Fast, unified linting/formatting |
All dependencies verified as latest stable versions per constitution requirements.