MyNews MCP Server
by iamvazghen
README.md
# MyNews — Vazghen's news feed
A multi-topic news aggregator that doubles as an **API your AI agents can read**.
One small Express server pulls RSS for 30 topics, serves a designed browser feed,
and exposes the same data as a REST + MCP API — including full readable article
text, not just headlines. **No API keys required.**
**Live:** https://mynews-nine.vercel.app · **Repo:** https://github.com/iamvazghen/mynews
```
/ landing page
/dashboard.html the live feed — filter, sort by newest/category, timeframe, search
/api/* the agent API (REST), also exposed over MCP via mcp.js
```
### Why I built it (portfolio note)
A focused full-stack project that solves a real problem I have: I read across ~29
unrelated topics, and I want my own agents to read them too. It demonstrates:
- **Full-stack JS** — Express API, RSS aggregation, in-memory caching, HTML→text article extraction.
- **Design** — a hand-built Cobalt design system (OKLCH tokens, Space Grotesk / Inter / JetBrains Mono), responsive, reduced-motion-safe; no UI framework.
- **Agent integration** — the same API is consumed by a browser **and** by LLM agents over **MCP**, so a scheduled agent can fetch headlines and then read full articles to summarize.
- **Shipping** — deployed to Vercel (serverless) from GitHub with CI on push, and to a private VPS as a systemd service.
It's deliberately small: ~1 backend file, 1 feed-config file, a static frontend, and an MCP shim.
## Run it locally
```bash
npm install
npm start # → http://localhost:3000
npm test # unit checks for reading-time + filter/sort + XML/date helpers
```
The feed is live immediately — every card shows when it dropped and how long it
takes to read; sort by newest or category, pick a timeframe, search.
## The live feed — what's wired and how to change it
Everything goes through **`feeds.js`** — the only file you edit. Each category is
`{ slug, label, feeds: [rss urls] }`. A dead/blocked URL is skipped so the page
never breaks; it is also logged to stderr and visible at `GET /api/health`.
- **Add a source:** drop another RSS URL into a category's `feeds` array, or a typed entry like `{ type: "json", adapter: "hn", url: "..." }` (see the source layer below).
- **Add a category:** append a new `{ slug, label, feeds }` object. Frontend, API and chips pick it up automatically.
- **Check what is alive:** `GET /api/health` returns every configured source with its status, item count and last error — and lists the ones that are skipped because a key isn't configured yet.
### Two URL patterns worth knowing
Some topics have no first-party RSS. Two query-based feeds cover **any** subject
without a publisher relationship:
```
hnrss.org/newest?q=<terms>&points=<n>&count=30 # Hacker News search
news.google.com/rss/search?q=<terms>+when:7d # Google News search
```
The `count=30` on `hnrss.org` is not optional — without it those endpoints
intermittently return HTTP 502. Google News items carry the real publisher in
`<source>`, and the server surfaces that as the card's source instead of
`news.google.com`.
## The 30 categories
| Group | Categories |
| --- | --- |
| **AI** | AI · Industry & Business · AI · Research & Practice · AI Agents |
| **Tech** | Coding & Tech · Open-Source / GitHub · Cybersecurity |
| **Money** | Startups · Business · Investments · **Stock Markets & Equities** · **Rates, Treasuries & Bonds** · **Economics & Inflation** · **FX Reference Rates (ECB)** · **Global Currencies & FX** · Crypto & Blockchain |
| **Science** | Space & Science · Climate & Energy · Health & Biotech |
| **Culture** | Gaming · Film & TV · Marvel & DC · Manga & Anime · Reading Recs · Sneakerhead · Watches |
| **Sports** | Basketball · Football (Soccer) · Sports · F1, Tennis, NFL |
| **World** | Germany · Ukraine · Armenia · Local |
Bold = new.
### Money & markets in detail
The financial categories are split so you can read one without the rest:
- **Stock Markets & Equities** — indices, earnings, market moves (CNBC, FT, Nikkei Asia, SCMP, Google News).
- **Rates, Treasuries & Bonds** — policy decisions and sovereign debt: Fed monetary policy, Fed press releases, ECB press + tender operations, ECB Market Information Dissemination, Bank of England, Bank of Japan, Google News on Treasury yields.
- **Economics & Inflation** — CPI and payrolls (BLS), euro-area statistics (ECB), macro desks (CNBC Economy, The Economist Finance & Economics), Google News on inflation / rate decisions / GDP.
- **FX Reference Rates (ECB)** — the *numbers*: one authoritative daily feed per currency for USD, JPY, CNY, NOK, SGD, CHF, GBP, SEK, DKK, AUD, CAD, NZD, INR, KRW, MXN, BRL, ZAR, TRY, THB and PLN (`1 EUR = N units`, published ~16:00 CET on TARGET days), plus the RBA's daily rates for AUD. These sit in their own category on purpose — a feed publishing one rate per day gets buried under a hundred breaking-news items once everything is sorted by date. Because rates are published on TARGET days, this category is marked `periodic`: the `since` window always keeps its newest day, so `?since=24` on a Sunday still returns Friday's rates instead of an empty list.
- **Global Currencies & FX** — the *why*: FXStreet, ForexLive, and per-currency Google News for USD, JPY, CNY, NOK, SGD, CHF, EUR and GBP, so you see the policy or risk event behind a move.
### The source layer — RSS plus JSON APIs
RSS alone left **8 of 30 categories under 10 items in a 24-hour window**: thin
for a daily digest, and with no depth material at all. Every category now draws
on two kinds of source.
**1. RSS / Atom** — the base layer. Free, keyless, no signup. 233 endpoints.
**2. JSON APIs** — normalised to the same item shape by `adapters.js`. 45
endpoints, all keyless:
| Adapter | Gives you |
| --- | --- |
| `arxiv` | preprints — the actual paper, not a headline about it |
| `openalex` | scholarly works, abstracts reconstructed from the inverted index |
| `devto` | working engineers writing about what they built |
| `stackexchange` | the questions themselves, with vote counts and answer counts |
| `hn` | Hacker News via the public Algolia index (newest-first, any topic) || `openlibrary` | books by subject, with covers and first-publish years |
| `frankfurter` | ECB FX reference rates for 30 currencies in one keyless call |
| `coingecko` | crypto market snapshot — price, 24h move, volume |
| `gnews` | 80,000+ sources in 41 languages, with the real publisher (keyed) |
| Google News queries | one search feed per category, so no niche is ever empty |
A feed entry is a plain URL string (RSS) or a typed object (JSON):
```js
"https://example.com/rss.xml" // RSS
api("hn", "https://hn.algolia.com/api/v1/...") // JSON adapter
api("guardian", "https://...api-key={{auth}}",
{ auth: { env: "GUARDIAN_KEY" } }) // needs a key
```
### Optional API keys (none required)
**Nothing is needed to run or deploy** — all 30 topics work from keyless
sources alone. 23 further sources are wired up but dormant until you supply
credentials; a source whose key is missing is **skipped, never failed**, so
you can commit the full source list and switch integrations on one env var at
a time. `GET /api/health` reports them under `skippedSources` with the reason.
| Integration | Register at | Free tier | Used for |
| --- | --- | --- | --- |
| Guardian Open Platform | [bonobo.capi.gutools.co.uk/register/developer](https://bonobo.capi.gutools.co.uk/register/developer) | 500/day, 60/min, non-commercial | deep sectioned coverage for 9 categories |
| NewsAPI | [newsapi.org/register](https://newsapi.org/register) | 100 req/day, attribution required | broad multi-publisher headlines |
| GNews | [gnews.io/register](https://gnews.io/register) | 100 req/day, 1 req/sec, 12h delay | 8 categories, 80k+ sources |
| TMDB | [themoviedb.org/settings/api](https://www.themoviedb.org/settings/api) | generous | film, TV and anime discovery |
| Twelve Data | [twelvedata.com/register](https://twelvedata.com/register) | 800 credits/day, 8/min | live FX quotes |
| Reddit OAuth2 | [reddit.com/prefs/apps](https://www.reddit.com/prefs/apps) | standard | subreddit depth (6 subreddits) |
Copy `.env.example` to `.env` and fill in what you want — it lists every URL
above with the exact steps for each. Reddit needs an app id + secret (choose a
"script" app; the redirect URI can be anything), the rest are single API keys.
Store them as env vars — never hardcode them. `.env` is gitignored.
Locally, `npm start` loads `.env` automatically (Node's `--env-file`). On Vercel,
set them under **Project → Settings → Environment Variables** — or with the
CLI, using `--value` so no trailing newline gets stored:
```bash
vercel env add GUARDIAN_KEY production --value "<key>" --sensitive
```
That last detail matters: a key with a trailing newline makes GNews answer
`400 "You did not provide an API key"` rather than "invalid key", and the
server now trims credentials on the way in so it cannot happen again.
The Guardian key is the highest-value one: it is the only source here with
genuinely deep per-topic coverage. Its free tier is **non-commercial** — if
MyNews ever earns money, request a commercial key at
`https://bonobo.capi.gutools.co.uk/register/commercial` instead.
### How caching works
Caching is **per source**, not per category, and each source refreshes on its
own schedule. That is what makes the free-tier quotas survivable:
| Source kind | Refresh | Why |
| --- | --- | --- |
| keyless RSS | 5 min | cheap, wants to be fresh |
| Guardian | 120 min | 500 requests/day, 9 sources → ~108/day |
| NewsAPI | 120 min | 100 requests/day, 3 sources → ~36/day |
| GNews | 240 min | 100 requests/day, 8 sources → ~48/day |
| TMDB, Twelve Data, Reddit | 60 min | no daily cap; Twelve Data is 8 credits/min |
A source can override this with `ttlMinutes`.
Hosts with hard request limits also get a **minimum gap between requests**, and
the gaps are measured rather than guessed:
| Host | Gap | Measured behaviour |
| --- | --- | --- |
| `content.guardianapis.com` | 150 ms | 60 req/**minute**, no per-second cap — 9 at once left the budget untouched |
| `gnews.io` | 1100 ms | genuinely 1 req/second — 8 concurrent gave six 429s |
| `api.twelvedata.com` | 1200 ms | 8 API credits/minute; a 5-symbol batch costs 5 |
The per-feed timeout is 10 s and a timeout is **not** retried — a slow host is a
dead host, and a second attempt would hold a concurrency slot for another full
timeout. A cold start over ~285 sources takes about 30 s on Vercel and is
instant on the VPS, where the process never goes cold.
### When a provider runs out of daily quota
Some providers answer `403` with *"you have reached your request limit for
today"* once a daily allowance is spent — GNews does this. The server
recognises that response, pauses the whole host until 00:00 UTC, and reports
it separately from a broken feed:
```json
{ "url": "https://gnews.io/...", "ok": false, "quota": true,
"error": "daily request limit reached" }
```
So a spent budget never turns into a wall of red in the health report, and the
feed is not hammered with requests that cannot succeed. Check the quota usage
before adding another provider on the same key — GNews's 100 requests/day is
easy to consume with eight sources refreshing four times a day.
**The result:** categories under 10 items in 24h went **8 → 0**, daily items
**962 → 1,822**, and `ai` alone went from 2 items / 1 source to **70 items /
30 sources**.
## The API
CORS is open, so any agent or page can call it.
```
GET /api/categories
→ [{ slug, label }, ...]
GET /api/sources?category=ai&since=24
→ { count, sources: [{ source, items }] }
every publisher currently in the feed, with its story count for the
window — the values for the `source` filter below.
GET /api/news?category=ai,crypto&since=24&sort=newest&q=bitcoin&source=theguardian.com&limit=50&offset=0
category comma-separated slugs (omit = all)
since only items from the past N hours (0 = all time)
sort newest | category
q keyword filter on title + snippet
source filter by publisher, e.g. theguardian.com
limit max items in this page (default 200, cap 500)
offset skip this many items — page through a feed larger than the cap
→ { count, offset, limit, hasMore, totalReadingMinutes,
items: [{ title, link, source, category, publishedAt,
readingMinutes, snippet, image }] }
`count` is how many stories match in total; `items` is the requested page.
GET /api/article?url=<article-url>
→ { url, title, author, published, source, wordCount, readingMinutes, content }
full readable body text, boilerplate stripped — what an agent reads to summarize a story
GET /api/health
→ { categories, deadFeeds: [{ slug, url, error, quota }],
skippedSources: [{ slug, url, reason }],
report: [{ slug, label, totalItems,
feeds: [{ url, adapter, ok, items, error, skipped, quota, reason }] }] }
per-source status for every configured source — so a thin category is
explainable instead of mysterious. `skipped` marks a source whose
credentials are not configured yet; `quota` marks one that has spent
its daily allowance and will recover on its own.
```
```bash
curl "https://mynews-nine.vercel.app/api/news?category=agents,ai&since=24&limit=10"
curl "https://mynews-nine.vercel.app/api/article?url=https://www.theverge.com/some-story"
curl "https://mynews-nine.vercel.app/api/health?category=currencies"
```
## Connect your AI agent — step by step
The API is public and keyless, so wiring any agent takes three steps.
1. **Point at the API.** Base URL `https://mynews-nine.vercel.app/api`.
2. **Get headlines.** `GET /api/news?category=agents,ai&since=24` → titles, sources, timestamps, read-time.
3. **Read the full story.** `GET /api/article?url=<link from step 2>` → clean body text to summarize.
That's enough for any framework (LangChain, a custom tool, a cron + curl). For
**MCP-native** agents, use the bundled server instead of raw HTTP:
```bash
npm i @modelcontextprotocol/sdk zod
NEWS_API=https://mynews-nine.vercel.app node mcp.js # speaks MCP over stdio
```
It exposes five tools: `get_news`, `get_article`, `list_categories`, `list_sources`
and `check_feeds`. The same server is also mounted over Streamable HTTP at
`POST /api/mcp` on the deployed app, so an agent can point at the URL instead
of spawning a process.
### OpenClaw (the simplest path)
Add one block to `~/.openclaw/openclaw.json` under `mcp.servers`, then restart the
gateway — every agent gains the tools:
```json
"mynews": {
"command": "/usr/bin/node",
"args": ["/home/openclaw/newsfeed/mcp.js"],
"env": { "NEWS_API": "https://mynews-nine.vercel.app" }
}
```
A morning-news cron then calls `get_news` for the day's headlines and `get_article`
for the full text of each story it wants to summarize — no scraping glue.
## Deployments
- **Vercel (public, online):** Express runs as a serverless function (`api/index.js` + `vercel.json`); `public/` is the static site. Auto-deploys on push to `main`.
- **VPS (private):** a systemd `--user` unit (`newsfeed.service`, `Restart=always`) on port 3000. Manage with `systemctl --user restart|status newsfeed` and `journalctl --user -u newsfeed -f`.
## Files
- `server.js` — API + cache + reading-time + article extraction + feed health + MCP over HTTP
- `feeds.js` — the 30 categories → sources (RSS URLs and JSON adapters; edit this)
- `adapters.js` — normalisers that turn JSON API responses into feed items
- `.env.example` — the optional API keys, documented
- `public/` — Cobalt-themed landing + feed (`index.html`, `dashboard.html`, `app.js`, `styles.css`, `landing.css`, `tokens.css`)
- `api/index.js` + `vercel.json` — Vercel serverless wiring
- `mcp.js` — MCP server (get_news / get_article / list_categories / list_sources / check_feeds)
- `test.js` — unit checks
## Notes on the feed layer
Four things `server.js` handles that a naive `rss-parser` setup gets wrong:
1. **Gzip.** Some hosts (deepmind.google) gzip their response; the client inflates it before parsing.
2. **Malformed XML.** Some publishers ship unescaped `&` inside image `srcset` attributes (restofworld.org). Bare ampersands are escaped before parsing; CDATA sections are left alone.
3. **Undated feeds.** GitHub Trending and Nikkei Asia ship no timestamps, so the `since` window used to drop them entirely and the category rendered "no stories". Undated items now inherit a bounded fallback date — the feed's own newest entry, or the fetch time when the feed has none — so they survive the 24-hour window instead of vanishing.
4. **Spent quotas.** A provider that has used up its daily allowance answers `403`, which looks exactly like a broken feed. The response body is read, the host is paused until 00:00 UTC, and the source is reported with `quota: true` instead of as a failure.
## The browser UI
`public/dashboard.html` is a thin client over the same API the agents use:
- **Filter** by category (multi-select chips), publisher (dropdown of every
source with its story count), timeframe, or keyword.
- **Sort** by newest, or by category — grouped, with a heading per topic.
- **Page** through the feed with *Load more*; `offset` walks past the 500-item cap.
- **Health pill** in the masthead shows how many sources are alive, paused on a
quota, unreachable, or waiting for an API key. Click it to refresh.
- **Shareable state** — the URL carries the filters, so a link opens the same
filtered feed. The landing page's topic tiles deep-link into a
pre-selected category.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues