Skip to main content
Glama
README.md
# hn-mcp

An MCP server for Hacker News. Built against the **2026-07-28** protocol
revision and **MCP Python SDK v2**.

Five read-only tools:

| Tool | Does |
|---|---|
| `list_stories` | Browse a feed (top / new / best / ask / show / job) |
| `get_story` | One story, job or poll by id |
| `get_discussion` | A story plus its comment thread, flattened and capped |
| `search_stories` | Full-text and date-filtered search over all HN history |
| `get_user` | A user profile |

## Run it

```bash
uv sync
uv run mcp dev src/hn_mcp/server.py          # Inspector, stdio
uv run mcp run src/hn_mcp/server.py --transport streamable-http   # HTTP
uv run pytest                                 # 45 tests, no network
```

## Architecture

```
server.py     MCP layer      tool schemas, annotations, response models
    |                        no I/O, no parsing
    v
hn_client.py  service layer  Firebase + Algolia, fan-out, BFS, caching
    |
    v
models.py     data layer     Story / Comment / User, normalization
cache.py                     TTL cache
```

Dependencies point one way only. `models.py` does not know MCP exists, which
is why it can be tested without starting a server.

## Design notes

**Two upstreams, one vocabulary.** Feeds, items and users come from HN's
Firebase API; search comes from HN's Algolia index, because Firebase has no
search endpoint at all. They name the same concepts differently (`by` vs
`author`, `score` vs `points`, `descendants` vs `num_comments`), so both
parse into the same models. The model never learns there were two APIs.

**Server-side fan-out.** Firebase returns id lists, so a 30-story feed is 31
requests. Doing that in the server costs one tool call; making the model walk
the ids costs 31 round trips with raw JSON in context each time.

**Comment trees are breadth-first.** HN ranks `kids` by its own ordering, so
breadth-first spends the comment budget on the highest-ranked comments across
the whole thread instead of exhausting it inside one argument. Nesting is
carried by an integer `depth` rather than nested objects, so the list stays
valid wherever it is cut.

**Truncation is reported, never silent.** When caps bite, `truncated` is true
and `truncation_hint` names the argument to raise. Each comment carries
`num_replies`, so the model can tell "no replies" from "replies you were not
shown" — a distinction it cannot otherwise make.

**Errors are prompts.** A failure says what went wrong, which tool to use
instead, and hands back the id needed to recover. Asking `get_story` for a
comment id returns the parent story's id rather than a validation error.

**Constraints live in types.** `Literal` feeds become schema enums;
`Field(ge=, le=)` becomes `minimum`/`maximum`. The docstring explains what
options *mean*; the type system enforces which are *legal*.

**Stateless.** The 2026-07-28 revision removed sessions, so the same code
serves stdio and Streamable HTTP with no session plumbing. Any request can
land on any instance.

## Verified against live HN

`search_stories` and `get_discussion` have been run against the real API.
Doing so found three bugs the fixtures could not:

- Breadth-first traversal with only a total cap spent the entire comment
  budget on top-level comments and returned one reply out of forty. Real
  threads are far wider than they are deep. Fixed with `max_top_level`.
- The truncation hint asserted a rule of thumb instead of measuring, and was
  wrong about which comments had hidden replies. It now counts them.
- Per-comment truncation was computed and discarded, leaving a trailing
  ellipsis indistinguishable from the author's own. Now `text_truncated`.

`tests/fake_hn.py` carries a wide thread modelled on the real one so the
first of those cannot regress.

## Not done yet

- `list_stories`, `get_story` and `get_user` have not been exercised against
  live HN, only against fixtures.
- Never loaded into a host, so tool *selection* is untested: whether a model
  picks `search_stories` over `list_stories` unprompted is the real measure
  of the docstrings, and it has not been measured.
- No rate limiting. Fine for stdio, needed before a public HTTP deployment.
- `search_stories` only searches stories, not comments (`tags` is fixed).

TDQS

A4.5/5.0

Scored across 5 tools

Disambiguation5/5

Each tool targets a distinct resource+action: search_stories is full-text history search, list_stories is live feeds, get_story is a single item by id, get_discussion is a comment thread, and get_user is a profile. The descriptions actively contrast the easily-confused pairs (search vs. feeds, story vs. discussion), so an agent can pick correctly without guessing.

Naming Consistency5/5

All five tools follow the same verb_noun snake_case pattern (search_stories, list_stories, get_story, get_discussion, get_user). The verbs also map cleanly onto intent: list for collections, search for queries, get for single lookups.

Tool Count5/5

Five tools is a tight, well-scoped surface for the Hacker News domain — search, browse, item, thread, user — with no filler or redundant variants. Each tool covers a workflow the others cannot answer.

Completeness4/5

Core lifecycle is covered: discovery via feeds/search, item retrieval, comment reading, and user lookup. The main gap is author-scoped browsing — get_user explicitly says to use search_stories for a user's submissions, but search_stories is documented only as text search, so per-user submission listing and single-comment/subthread fetch are workarounds rather than first-class operations.

Maintenance

ActivityMaintained
ResponsivenessNo issues