medium-reader-mcp
v0.8.0
Published
MCP server for reading your Medium feed and member-only posts
Maintainers
Readme
Medium Reader MCP
Ask Claude what's new in your Medium feed, and have it read member-only stories for you.
Quick start · Tools · Daily digest · Headline ranking with Jev · Logging in · Troubleshooting
An MCP server for your own Medium account:
- Read: your Following and For you feeds, full member-only posts, search, reading lists, follows and history.
- Act, when you ask: follow and unfollow, mute, save to lists, clap.
- Daily digest: with Hermes Agent, a scheduled morning digest. Jev can rank every headline against your interests before an LLM reads anything.
- No API keys for Medium: you log in once in a browser window.
What it uses
| What | Needed? | |---|---| | Your Medium account | Yes. A membership is needed to read member-only stories in full | | Node.js 26+ | Yes | | Chrome or Edge | Only for the browser login (you can paste cookies instead) | | A Jev-style decision model | Optional. Ranks digest headlines against your interests in about 2 s per 100. Any provider with the same decisions API works, set by URL, key and model like an OpenAI-compatible client. The default is TypeSafe's Jev on OpenRouter, about $0.03 per 1,000 headlines | | An MCP client | Claude Code, Claude Desktop, Cursor, VS Code, Hermes Agent… |
How it talks to Medium:
- Medium's own web endpoint: Medium has no reading API, so the server calls the GraphQL endpoint medium.com uses, with your session. It sees only what you see logged in.
- HTTP/1.1 through
undici: Medium's Cloudflare blocks Node's HTTP/2fetch, which Node 26 uses by default.
Quick start
npm install -g medium-reader-mcp
medium-reader-mcp login # a browser window opens; sign in to Medium as usual
medium-reader-mcp install # registers the server with Claude CodeFrom source instead: git clone https://github.com/wkbaran/medium-reader-mcp.git, then npm install && npm run build, and use node dist/cli.js in place of medium-reader-mcp.
Restart Claude Code, then try:
- "What's new in my Medium feed today?"
- "Summarize that Towards AI post about schema drift."
- "Which authors I follow post the most but I never read?"
- "Propose an interests.md from my Medium activity."
- "Save this post to my AI list." (Claude asks before every change)
Point the client at the medium-reader-mcp command (or node with the absolute path of dist/cli.js for a source checkout), after running login:
{ "mcpServers": { "medium-reader": { "command": "medium-reader-mcp" } } }- Claude Desktop:
claude_desktop_config.json(Settings → Developer → Edit Config) - Cursor:
~/.cursor/mcp.json - VS Code:
.vscode/mcp.json, using"servers"instead of"mcpServers"and adding"type": "stdio" - Claude Code, manually:
claude mcp add --scope user medium-reader -- medium-reader-mcp
Tools
How to name things:
- Authors:
@usernameor a profile URL. - Publications: a name, slug or URL, including custom domains.
- Posts: any Medium link, or the post's hex id.
Reading (all read-only)
| Tool | What it returns |
|---|---|
| get_feed | Your Following feed (default) or For you, newest first. since takes "7d", "48h" or a date |
| read_post | A full post as Markdown. Member-only stories you can't access are flagged as previews |
| get_recent_posts · search_posts | One author's or publication's latest posts · keyword search across Medium |
| list_following · list_reading_lists · get_list | Who you follow · your lists · the posts in one list |
| get_reading_history | Posts you've read, most recent first (Medium gives no read dates) |
| rate_headings | Rates headlines against conditions you define, using your MCP client's own model (sampling) |
| interests_evidence | What your activity says about your taste, for drafting an interests.md |
| auth_status | Who you're logged in as, and whether you're a member |
Account changes (clients ask first; the server re-checks with Medium afterwards)
| Tool | What it does |
|---|---|
| follow / unfollow | Follow or unfollow an author or publication. An ambiguous name lists the matches instead of guessing |
| mute / unmute | Hide an author's or publication's posts, including an author's posts in publications you follow. Private |
| save_to_list / remove_from_list | Add a post to, or remove it from, a list |
| clap / undo_clap | Clap (up to Medium's 50 per post), or take your claps back |
Follows and claps are visible to the author, and named lists are public.
Digest (only when MEDIUM_READER_DIGEST_DIR is set; plain-text output for agent harnesses)
| Tool | What it does |
|---|---|
| digest_begin | Collects what's new, ranks and filters it, and returns a work list with refs (F3, T1, Y12). The same list is also in structuredContent, for clients that run the digest from code |
| digest_finish | Takes the picks by ref, renders the message and saves state. Safe to repeat |
| digest_status · mark_reported | Inspect the state · repair it |
| save_interests_proposal | Saves a drafted interests.md as interests.proposed.md, never over the real one |
A worked example of a whole run, with each tool's arguments and output: docs/digest-tools.md.
Daily digest with Hermes Agent
Hermes Agent runs skills on a schedule and delivers to Discord and other chats. hermes/SKILL.md turns this server into a morning digest:
- What it covers: new posts from Following, Medium's top picks, and your personal "For you" positions 25–100.
- How it's chosen: Jev ranks and filters the headlines, then subagents read the best 20–25 in full.
- What you get: one message, with ⭐ "Read in full" picks that each have a summary and a reason.
- What the model does: only the judging. Fetching, state and layout are code, which took a Sonnet run from about $2.50 to $0.40. State files, locks and run files use job-ledger.
Setup
- Build (
npm ci && npm run build) and copydist/,package.jsonandpackage-lock.jsonto where Hermes can see them, e.g./opt/data/mcp/medium-reader-mcp. Then runnpm ci --omit=dev --omit=optionalthere. - Log in on a machine with a browser (
medium-reader-mcp login), then copy~/.config/medium-reader/auth.jsonto the Hermes host, e.g./opt/data/mcp/medium-reader-home/. - Create the digest directory, e.g.
/opt/data/sandbox/medium_digest, with aninterests.md(start fromhermes/interests.example.md, or draft one from your activity). - Register the server in Hermes's
config.yaml:mcp_servers: medium-reader: command: node args: ["/opt/data/mcp/medium-reader-mcp/dist/cli.js"] env: MEDIUM_READER_HOME: /opt/data/mcp/medium-reader-home MEDIUM_READER_DIGEST_DIR: /opt/data/sandbox/medium_digest MEDIUM_READER_DIGEST_TZ: America/New_York MEDIUM_READER_CLASSIFIER: jev # rank with Jev (default: sampling) MEDIUM_READER_JEV_API_KEY: ${JEV_OPENROUTER_API_KEY} # from Hermes's .env # MEDIUM_READER_JEV_URL: https://openrouter.ai/api/alpha/decisions # the default; any decisions-API endpoint - Install the skill: copy
hermes/SKILL.mdtoskills/productivity/medium-digest/andhermes/medium_digest_start.shtoscripts/. Then edit the skill's Settings block. - Schedule it (run as the
hermesuser). The job needs only thedelegationandmedium-readertoolsets:hermes cron create "0 7 * * *" "Run the medium-digest skill and deliver the digest." \ --name medium-digest --skill medium-digest --script medium_digest_start.sh --deliver discord:<channel-id>
More detail is in docs/digest.md: why the server has harness-specific tools, customizing, state and repairs, and limits.
Headline ranking with Jev
The digest's first step is deciding which of a day's 100–200 headlines matter to you, before anything is read. That's the classifier (src/classifier/), kept separate from summarizing and presenting.
- Jev is a "decision model" from TypeSafe. It returns typed answers with probabilities, not text. For every headline it gives:
- a rank (how much you'd want it, judged against
interests.md) - a skip probability (whether it matches your Skip list)
- a rank (how much you'd want it, judged against
- In the work list: each section is sorted best first with a rank column. Confident skips are dropped. Low ranks sink, so the agent starts from the top, and on a big day the bottom is what gets cut.
- Measured on real labels:
- Against a local 27B model: Jev's top 10 were all posts the reader wanted, against 7 of 10, and it took 3 s instead of 178 s for 160 headlines.
- Details.
- Backends (
MEDIUM_READER_CLASSIFIER):jev: ranks and skips. The server calls the decisions API itself over HTTPS, not through sampling.sampling(default): skips only. The server asks your MCP client's own model to rate each headline, which is what MCP sampling means: the server borrows the client's LLM rather than having its own (in Hermes,auxiliary.mcpormcp_servers.<name>.sampling.model). It doesn't rank, so the order of posts is left to the agent's model when it shortlists.off.
- Any Jev-style provider: the
jevbackend speaks the decisions API (POSTmodel,state,questions→answers), and you point it at a provider the way you'd point an OpenAI-compatible client at a local model:MEDIUM_READER_JEV_URL: the endpoint. Default: OpenRouter'shttps://openrouter.ai/api/alpha/decisions. TypeSafe's System One API (…/v1/systemone) and compatible or self-hosted servers work too.MEDIUM_READER_JEV_API_KEY: the bearer token (falls back toOPENROUTER_API_KEY). It can be empty for a server without auth.MEDIUM_READER_JEV_MODEL: the model id. Defaulttypesafe/jev-1.13; TypeSafe's own API usesjev-1.13.
- Tune it to you: label your own headlines with one keypress each, and the tools recommend thresholds and edits to
interests.md. They can also draft aninterests.mdfrom your reading lists, follows and history.
Everything about it is in docs/classifier.md: settings, the tuning loop, drafting from activity, evidence, and adding a backend.
Logging in
- Browser (default):
loginopens Chrome or Edge on Medium's sign-in page. The session is saved as soon as you're in and lasts about a year. Re-runningloginusually needs no typing. - Paste:
login --paste(or--stdin) takes thesidanduidcookies in any format: aCookie:header, a Cookie-Editor export, orcookies.txt. - No restart needed: the server re-reads the session on every call.
- Tips:
- If Medium emails you a sign-in link, paste it into the
loginwindow. - The window must be visible, because Cloudflare blocks headless browsers.
- If Medium emails you a sign-in link, paste it into the
| Command | What it does |
|---|---|
| medium-reader-mcp login | Log in (--paste / --stdin to paste cookies) |
| medium-reader-mcp status | The logged-in account, membership, and whether the session works |
| medium-reader-mcp logout | Delete the session (--all also deletes the login browser profile) |
| medium-reader-mcp install | Register with Claude Code (--scope user\|project\|local) |
| Variable | Purpose |
|---|---|
| MEDIUM_SID, MEDIUM_UID, MEDIUM_COOKIE | Session cookies, overriding the saved session (containers, CI) |
| MEDIUM_READER_HOME | Config directory (default ~/.config/medium-reader) |
| MEDIUM_BROWSER_PATH | A Chromium-based browser for login |
| MEDIUM_READER_DIGEST_DIR | Digest directory; setting it turns on the digest tools |
| MEDIUM_READER_DIGEST_TZ | Time zone for digest dates (default TZ, then UTC) |
| MEDIUM_READER_DIGEST_KEEP | Reported post ids to keep (default 3000) |
| MEDIUM_READER_DIGEST_STYLE | discord (default) or markdown |
| MEDIUM_READER_CLASSIFIER | jev, sampling (default) or off |
| MEDIUM_READER_JEV_URL | Decisions-API endpoint for jev (default OpenRouter's) |
| MEDIUM_READER_JEV_API_KEY | Bearer token for that endpoint (default OPENROUTER_API_KEY) |
| MEDIUM_READER_JEV_MODEL | Model id (default typesafe/jev-1.13) |
| MEDIUM_READER_DIGEST_SKIP_THRESHOLD | Skip probability at which a headline is dropped (default 0.7) |
| MEDIUM_READER_DIGEST_RANK_FLOOR | Rank below which posts are collapsed to one line (default off) |
| MEDIUM_READER_DIGEST_RANK_FLOOR_MODE | exclude: leave posts below the floor out entirely and only count them (default compact) |
| MEDIUM_READER_DIGEST_FETCH_BUDGET | How long digest_begin may spend, rating included (default 170s, which fits a 300 s agent tool timeout). Raise it where the client waits longer, e.g. 10min |
| MEDIUM_READER_DIGEST_GIST_MAX, _STAR_GIST_MAX, _WHY_MAX, _MAX_STARRED | Longest one-line gist, ⭐ gist and why line in characters, and the most ⭐ picks (default 280, 400, 250, 8). Longer text is cut at a clause or word break, with a warning. The values are written into digest_finish's argument descriptions and returned by digest_begin (limits), so the model or job is told the same numbers |
| MEDIUM_READER_RATE, MEDIUM_READER_CONCURRENCY, MEDIUM_READER_MIN_GAP | Pacing of requests to Medium, e.g. 60/min, 2, 250ms (default: none). See paced-fetch |
| MEDIUM_READER_RETRIES, MEDIUM_READER_MAX_RETRY_WAIT | Retries after a 429 (or a 5xx or network error on a query; mutations aren't sent twice), waiting 0.5 s then 1 s or Medium's Retry-After; and the longest Retry-After worth waiting (default 2, 60s; longer fails at once) |
Privacy and security
- Your session goes only to
medium.com. Custom-domain posts are fetched from medium.com by id. - It's stored in
~/.config/medium-reader/auth.json, mode600. Nothing is stored in the repo. - No telemetry. Requests go only to Medium, and to your Jev provider if it's on (headlines and your interests only).
- Reading through this server doesn't add posts to your Medium reading history.
package-lock.jsonpins every dependency; install withnpm ci.
How Medium's feeds behave
The digest's design rests on a few findings, written up in docs/feeds.md:
- "For you" is a fixed list of about 1,000 posts. Paging to its end makes Medium rebuild your homepage list.
- Positions 0–25 are popular posts for everyone; positions 25–250 are the most personal part of the list.
- Muting an author is the only way to hide their posts in publications you still follow.
Troubleshooting
| Symptom | Fix |
|---|---|
| "Not logged in" / "session was rejected" | medium-reader-mcp login |
| "Only a preview was returned" | Not a member, or the session expired; status shows which |
| "Cloudflare protection blocked the request" | Usually temporary. If it persists, Medium changed its bot rules; please open an issue |
| The server doesn't appear in /mcp | Restart Claude Code |
| login can't find a browser | Install Chrome, set MEDIUM_BROWSER_PATH, or use --paste |
| The digest says Classifier … unavailable | For jev, check MEDIUM_READER_JEV_URL and the key; for sampling, the client's sampling setup |
Development
npm ci && npm test # vitest against a fake GraphQL endpoint; no network needed
npm run typecheck && npm run buildsrc/medium/: the GraphQL client and queries.src/digest/: the digest tools.src/classifier/: the headline classifier, shared with substack-reader-mcp.tools/classifier/: label, score, analyze, propose.experiments/jev/: the trials behind the classifier.- CLAUDE.md: how Medium's API actually behaves. Read it before changing
src/medium/.
Disclaimer
An independent project, not affiliated with or endorsed by Medium or TypeSafe. It uses Medium's undocumented web endpoints, which can change without notice. Use it with your own account, within Medium's Terms of Service.
License
MIT © 2026 Bill Baran.
