Connecting your LLM to your RSS reader with MCP and OAuth 2.0 PKCE
SlashFeed has had an MCP server since early in its life. It was mounted at /dev/mcp, started only in development, had zero authentication, and gave every connected client full read/write access to the entire database. That’s fine for local experimentation — you, your laptop, your terminal — but the moment you want to talk to your feeds from Claude Desktop or Cursor while the app is running on a real server, you’re stuck.
This post is about fixing that: a production-safe /api/mcp endpoint where each user connects with their own credentials and every tool query is scoped strictly to their data.
The problem with the dev-only server
The original server used Anubis, a library that wraps the Model Context Protocol into a Phoenix-friendly OTP process. It was forwarded to by the Phoenix router only when dev_routes: true was compiled in:
if Application.compile_env(:slashfeed, :dev_routes) do
scope "/dev" do
pipe_through :mcp
forward "/mcp", Anubis.Server.Transport.StreamableHTTP.Plug,
server: SlashFeed.MCP.Server
end
end
Three problems:
-
Dev-only. The compile-time
ifmeans the route literally does not exist in production. - No authentication. Any HTTP client that can reach the port gets full system access.
-
No user isolation.
list_feedsreturns every feed in the database. There is no concept of “the current user’s feeds.”
The new endpoint
The replacement is a UserServer — a separate Anubis server — mounted at /api/mcp in all environments, including production:
# lib/slashfeed/mcp/user_server.ex
defmodule SlashFeed.MCP.UserServer do
use Anubis.Server,
name: "slashfeed-user",
version: "1.0.0",
capabilities: [:tools]
component(SlashFeed.MCP.UserTools.ListMyFeeds)
component(SlashFeed.MCP.UserTools.ListMyEntries)
component(SlashFeed.MCP.UserTools.SearchMyEntries)
component(SlashFeed.MCP.UserTools.GetMyStats)
component(SlashFeed.MCP.UserTools.MarkEntryRead)
component(SlashFeed.MCP.UserTools.SaveForLater)
component(SlashFeed.MCP.UserTools.SubscribeToFeed)
@impl true
def init(_client_info, frame) do
case Registry.pop(self()) do
{:ok, user} -> {:ok, %{frame | private: %{current_user: user}}}
:error -> {:error, "Unauthorized"}
end
end
end
The router pipes it through a dedicated :user_mcp pipeline:
pipeline :user_mcp do
plug :fetch_query_params
plug SlashFeedWeb.Plugs.MCPAuth
end
scope "/api" do
pipe_through :user_mcp
forward "/mcp", Anubis.Server.Transport.StreamableHTTP.Plug,
server: SlashFeed.MCP.UserServer
end
The MCPAuth plug authenticates the request. The Registry.pop(self()) in init/2 retrieves the resolved user. Between those two lines is the interesting engineering.
The Anubis/Plug bridge: why ETS?
This is the core challenge. A Phoenix Plug runs before Anubis takes over the connection and creates its Frame (the per-session context object). The plug can authenticate the request and stash the user in conn.assigns, but by the time UserServer.init/2 is called, the conn is gone — Anubis only passes client_info and an empty frame.
There is no official Anubis API yet for passing per-request data into init/2. The practical solution is an ETS table keyed on the request process PID:
# MCPAuth plug — after validating the token:
Registry.put(self(), user)
# UserServer.init/2 — called synchronously on the same process:
case Registry.pop(self()) do
{:ok, user} -> {:ok, %{frame | private: %{current_user: user}}}
:error -> {:error, "Unauthorized"}
end
This works because Anubis’s StreamableHTTP.Plug calls init/2 synchronously, before it spawns the long-lived session GenServer. self() in the plug and self() in init/2 are the same PID.
The registry is a simple GenServer that owns a named ETS table and monitors every process it stores an entry for:
defmodule SlashFeedWeb.Plugs.MCPAuth.Registry do
use GenServer
@table __MODULE__
def put(pid, user) do
:ets.insert(@table, {pid, user})
GenServer.cast(__MODULE__, {:monitor, pid})
:ok
end
def pop(pid) do
case :ets.take(@table, pid) do
[{^pid, user}] -> {:ok, user}
[] -> :error
end
end
def handle_cast({:monitor, pid}, %{monitors: monitors} = state) do
ref = Process.monitor(pid)
{:noreply, %{state | monitors: Map.put(monitors, ref, pid)}}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
:ets.delete(@table, pid)
{:noreply, state}
end
end
:ets.take/2 atomically reads and deletes in one call, so each entry is consumed exactly once. The Process.monitor in the GenServer handles the edge case where the connection process crashes between the plug writing to ETS and init/2 reading from it — the entry is cleaned up automatically when the monitor fires.
Authentication: two modes in one plug
The /api/mcp endpoint accepts two credential types, tried in order:
Mode 1 — OAuth 2.0 Bearer token (boruta)
SlashFeed already runs boruta_auth as its full OAuth 2.0 / OpenID Connect authorization server. Tokens issued by boruta are validated by calling Boruta.Oauth.Authorization.AccessToken.authorize/1, which checks signature, expiry, and revocation in a single round-trip.
This is the preferred mode for interactive clients. The user signs in through their normal SlashFeed account — no separate credentials to manage.
Mode 2 — Custom 64-char bearer token
For headless scripts and clients that can’t complete a browser OAuth flow, users can generate a permanent personal token from their Settings page. It’s a 32-byte CSPRNG value, hex-encoded to 64 characters. Only the SHA-256 hash is stored in the database — the same pattern Phoenix uses for session tokens.
Dispatching between the two
The plug tells them apart by length:
def call(conn, _opts) do
with :error <- try_oauth(conn),
:error <- try_custom_token(conn) do
reject(conn)
else
{:ok, conn} -> conn
end
end
defp try_oauth(conn) do
with {:ok, raw} <- extract_bearer(conn),
# Custom tokens are always exactly 64 hex chars —
# skip the boruta round-trip for them.
false <- byte_size(raw) == 64,
{:ok, token} <- Authorization.AccessToken.authorize(value: raw),
%User{} = user <- load_user(token.sub) do
conn =
conn
|> assign(:oauth_token, token)
|> assign(:mcp_user, user)
Registry.put(self(), user)
{:ok, conn}
else
_ -> :error
end
end
defp try_custom_token(conn) do
with {:ok, raw} <- extract_any_token(conn),
true <- byte_size(raw) == 64,
%User{} = user <- Accounts.get_user_by_mcp_token(raw) do
conn = assign(conn, :mcp_user, user)
Registry.put(self(), user)
{:ok, conn}
else
_ -> :error
end
end
The 64-char heuristic is sound because boruta tokens are either compact JWTs (which start with eyJ) or opaque strings of a different length. There’s no overlap in practice.
Custom tokens also have a ?token=<value> query-string fallback for clients that cannot set headers. Documented as HTTPS-only because access logs capture query strings.
The 401 response
When both modes fail, the plug halts with HTTP 401 and a WWW-Authenticate header that includes authorization_uri:
WWW-Authenticate: Bearer realm="SlashFeed MCP",
authorization_uri="https://slashfeed.app/oauth/authorize"
OAuth-aware clients (Claude Desktop, Cursor) read this header and automatically open a browser for the PKCE sign-in flow. The user never has to manually configure an auth URL — the client discovers it from the 401.
The JSON body also includes a discovery_url pointing at /.well-known/openid-configuration, so clients that support OIDC can auto-discover the token endpoint, JWKS, and supported scopes from a single URL.
The custom token lifecycle
The database side is minimal: two new columns on users — mcp_token_hash (unique, partial index on non-null values) and mcp_token_generated_at.
Generating a token:
def generate_mcp_token(%User{} = user) do
raw = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)
hash = :crypto.hash(:sha256, raw) |> Base.encode16(case: :lower)
now = DateTime.utc_now(:second)
{:ok, updated} =
user
|> User.mcp_token_changeset(%{mcp_token_hash: hash, mcp_token_generated_at: now})
|> Repo.update()
{raw, updated}
end
The raw token is returned to the caller once and never stored anywhere. Only the SHA-256 digest lives in the database. Lookup is Repo.get_by(User, mcp_token_hash: hash) — a single indexed read.
Generating a new token immediately overwrites the old hash, invalidating any existing sessions using the previous token. Revoking sets both columns to nil.
The settings UI shows the raw token exactly once in a dismissible banner with a copy button, displays the generation date as a status badge, and switches the button label from “Generate Token” to “Rotate Token” once a token exists.
Per-user tools
Every tool reads frame.private[:current_user] and passes it to the existing context functions, which already enforce user-level isolation:
defmodule SlashFeed.MCP.UserTools.ListMyFeeds do
use Anubis.Server.Component, type: :tool
schema do
end
def execute(_params, frame) do
user = frame.private[:current_user]
user_feeds = Feeds.list_user_feeds(user)
lines =
Enum.map(user_feeds, fn uf ->
feed = uf.feed
title = uf.custom_title || feed.title || "(no title)"
cat = if uf.category, do: " [#{uf.category.name}]", else: ""
last = if feed.last_fetched_at, do: DateTime.to_iso8601(feed.last_fetched_at), else: "never"
error = if feed.fetch_error, do: " ⚠ #{feed.fetch_error}", else: ""
"[#{feed.id}] #{title}#{cat}\n url: #{feed.url} | last_fetched: #{last}#{error}"
end)
result =
if Enum.empty?(lines),
do: "You have no feed subscriptions.",
else: Enum.join(lines, "\n\n")
{:reply, Response.tool() |> Response.text(result), frame}
end
end
The full set of tools:
| Tool | What it does |
|---|---|
list_my_feeds |
All subscribed feeds with category, fetch time, and error state |
list_my_entries |
Recent unread entries, filterable by feed or category |
search_my_entries |
ILIKE full-text search across the user’s subscribed feeds |
subscribe_to_feed |
Add a new RSS/Atom feed to the user’s subscriptions |
mark_entry_read |
Mark a single entry as read |
save_for_later |
Save an entry to the Read Later list |
get_my_stats |
Reading streak, total reads, and top feeds for a configurable window |
None of these can return data from feeds the user isn’t subscribed to. list_my_entries calls Feeds.list_unread_entries(user, opts), search_my_entries joins user_feeds filtered by user.id, and so on. The context layer enforces the boundary; the tools just call it.
Configuring your MCP client
The /api/mcp endpoint supports OAuth 2.0 with OIDC discovery, which means most clients can auto-configure themselves with just a URL and a client_id.
Step 1 — register an OAuth client on the server (run once):
mix slashfeed.oauth.create_client \
--name "Claude Desktop" \
--redirect-uri "http://localhost:8888/callback" \
--grants "authorization_code,refresh_token" \
--scopes "feeds:read,entries:read,entries:write,categories:read,stats:read" \
--pkce \
--access-ttl 3600 \
--refresh-ttl 2592000
# → client_id: 019f1a2b-... (no secret — PKCE public client)
Step 2 — configure your client:
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json):
{
"mcpServers": {
"slashfeed": {
"type": "http",
"url": "https://slashfeed.app/api/mcp",
"oauth": {
"client_id": "019f1a2b-...",
"authorization_url": "https://slashfeed.app/oauth/authorize",
"token_url": "https://slashfeed.app/oauth/token",
"scope": "feeds:read entries:read entries:write categories:read stats:read"
}
}
}
}
Cursor (~/.cursor/mcp.json) uses the same shape. Zed accepts a discovery_url instead of explicit endpoint URLs:
{
"assistant": {
"version": "2",
"mcp_servers": {
"slashfeed": {
"type": "http",
"url": "https://slashfeed.app/api/mcp",
"oauth": {
"client_id": "019f1a2b-...",
"discovery_url": "https://slashfeed.app/.well-known/openid-configuration",
"scope": "feeds:read entries:read stats:read"
}
}
}
}
}
On first connection the client opens a browser, you sign in with your existing SlashFeed account, and tokens are stored and refreshed automatically from then on. For clients that don’t support OAuth, use a personal bearer token from Settings instead:
{
"mcpServers": {
"slashfeed": {
"type": "http",
"url": "https://slashfeed.app/api/mcp",
"headers": {
"Authorization": "Bearer a3f9e2b1c4d5..."
}
}
}
}
What I’d do differently
Richer tool responses. The current tools return plain text. The MCP spec supports structured content — JSON, markdown tables, embedded URIs. A list_my_feeds that returns structured data would let the LLM do more interesting things with it: grouping by category, sorting by unread count, building a reading plan.
Per-token scopes for custom tokens. Right now the personal bearer token grants everything. It would be better to let users restrict a token to, say, entries:read only — useful for read-only integrations where you don’t want an LLM accidentally subscribing to new feeds.
Token last-used tracking. OAuth tokens are tracked by boruta. Custom bearer tokens have mcp_token_generated_at but no mcp_token_last_used_at. A running counter and timestamp would let users see whether their token is actually being used.
Rate limiting. The REST API has per-user rate limiting. The MCP endpoint doesn’t. Given that an LLM can call tools in a tight loop — especially with an agentic framework running tool chains — this is worth adding before it becomes a problem.
A “Connected Applications” page. Users can revoke the personal bearer token from Settings. OAuth grants, however, live in the boruta token store and can only be managed by an admin today. A user-facing page listing active OAuth clients with per-client revocation would close that gap.