Ask an assistant about recent developments and you get one of two things: whatever was in its training data, or the top few web search results. What you cannot ask is the thing you actually want — "what came out in Rust tooling in the last two weeks" — scoped to a field, across the sources that cover it.
Those sources exist. They are RSS feeds. They are just not reachable from an AI tool.
So I built a directory of them and gave it an MCP server. This post is about four decisions that only became obvious after shipping, not a tour of the product.
2,907 RSS feeds across 19 languages and 37 categories, holding 51,483 articles. An MCP server exposes it to AI tools.
claude mcp add --transport http rss-atlas https://mcp.rssatlas.com/mcp
Other clients take it in settings.json
:
{ "mcpServers": { "rss-atlas": { "httpUrl": "https://mcp.rssatlas.com/mcp" } } }
It runs on Cloudflare Workers (site and MCP server), Supabase (Postgres), and Cloud Run Jobs (crawler). There is a no-auth demo at https://mcp.rssatlas.com/demo
if you want to poke at it.
The server exposes exactly four tools: search_feeds
, get_feed
, get_articles
, get_rankings
.
Building "fetch articles from only my favourited feeds" wanted to be a fifth tool — get_favorite_articles
. It became a parameter instead:
favorites_only: z.boolean().optional()
.describe("Restrict results to the signed-in user's favorites"),
The reason is that every added tool is another choice the model has to get right. With get_articles
and get_favorite_articles
side by side, the model decides between them on every call. As a parameter it is not a decision at all — it is an argument. One less branch.
The rule now is: before adding a tool, check whether an existing tool's parameters can carry it.
This one cost the most time.
MCP lets a tool return isError: true
in its result. I used that for unauthenticated requests at first — HTTP 200, error in the payload.
Clients do not show a sign-in prompt for that.
They decide "authentication needed" from HTTP 401 plus a WWW-Authenticate header. A 200 means the call succeeded as far as the transport is concerned, so whatever is inside gives the client no reason to start an auth flow. To the user it looks like a tool that throws a confusing error.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.rssatlas.com/.well-known/oauth-protected-resource"
The authorization server is discovered from that header. So: authentication is expressed at the HTTP layer, not in tool results. Ordinary failures inside a tool call — no matches, bad argument — are still fine as isError
. Do not mix the two.
For anything scoped to a user, the user id is not a tool parameter. The only identity used is sub
from the verified access token:
if (favorites_only && !caller.userId) return fail("favorites_only requires a signed-in user");
Validation would have caught a wrong id. But with MCP the model composes the arguments, so an argument that can name another user is a shape that invites the mistake. If the field does not exist, the class of bug does not exist.
Structure beats validation for anything that must never be passed.
Cloudflare Workers has a Rate Limiting binding. I used it for the MCP server's limits.
It did not work — more precisely, it did not block.
The docs say plainly that it is not intended for precise counting. It is distributed, counts do not settle instantly across edges, and near the threshold requests get through. It is for shaping rough traffic, not for reliably stopping the 31st call.
Rewrote it on a Durable Object, which serialises to a single instance and therefore counts exactly. Measured afterwards: the 31st request returns 429 for authenticated users, the 11th for the demo.
The habit that came out of this: after implementing a rate limit, hammer past the threshold and watch it actually refuse.
Worth stating because it drove several decisions: there is no summarisation and no AI classification in the product. Classification is rules and thresholds. Summarising happens in your own LLM.
The reason is that the reader is already paying for a model. Calling one server-side means they pay twice, indirectly. Over MCP the summary belongs on their side anyway — faster, cheaper, and their choice of model.
For the same reason feed content is capped at title + 500-character excerpt + link. Nothing is stored in full. You read it at the source, which is what RSS was for.
Not MCP, but it ate a comparable amount of time.
Conditional GET plus redirects: /feed
→ 301 → /feed/
→ 304 Not Modified.
304 is a 3xx. Treat "3xx means redirect" literally and you go looking for a Location
header, which a 304 does not have, and you error out.
The nasty part is that only feeds that once succeeded well enough to store an ETag can hit this — so your best-behaved sources break first, and because the listing still renders, they just quietly go stale.
Fixing the redirect check to mean "3xx except 304" and requeueing took failures from 501 to 13.
isError
on a 200 never triggers a sign-in promptDemo, no auth required: https://mcp.rssatlas.com/demo
— and the directory is at rssatlas.com.