Skip to main content
Glama
Substack Reader MCP

Substack Reader MCP

by wkbaran
README.md
<div align="center">

# Substack Reader MCP

**Ask Claude what's new in your Substack subscriptions, and have it read the posts for you.**

![npm](https://img.shields.io/npm/v/substack-reader-mcp) ![Node 26+](https://img.shields.io/badge/node-26%2B-339933?logo=nodedotjs&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white)
![MCP](https://img.shields.io/badge/MCP-stdio-6E56CF)
![Tools](https://img.shields.io/badge/tools-17%20%2B%205%20digest-informational)
![Jev](https://img.shields.io/badge/ranking-Jev%20decision%20model-orange)
![License: MIT](https://img.shields.io/badge/license-MIT-blue)

[Quick start](#quick-start) · [Tools](#tools) · [Daily digest](#daily-digest) · [Headline ranking with Jev](#headline-ranking-with-jev) · [Logging in](#logging-in) · [Troubleshooting](#troubleshooting)

</div>

An MCP server for your own Substack account:

- **Read:** your subscriptions, a combined feed, full posts (including paid ones you have access to), and search.
- **Your reading activity:** reading history with how far you read each post, plus saved and hearted posts.
- **Chats:** publication chats and direct messages.
- **Act, when you ask:** subscribe to and unsubscribe from free newsletters.
- **Daily digest:** a scheduled morning digest, run by a standalone job such as [substack-digest](https://github.com/wkbaran/substack-digest) or by an agent such as [Hermes Agent](https://github.com/NousResearch/hermes-agent). [Jev](#headline-ranking-with-jev) can rank posts against your interests before they're read.
- **No API keys for Substack:** you log in once in a browser window.

## What it uses

| What | Needed? |
|---|---|
| Your [Substack](https://substack.com) account | Yes. Free and paid subscriptions both work |
| Node.js 26+ | Yes |
| Chrome or Edge | Only for the browser login (you can paste a cookie instead) |
| A [Jev](docs/jev/README.md)-style decision model | Optional. Ranks digest posts against your interests in a few seconds. 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](https://openrouter.ai/typesafe/jev-1.13), about $0.03 per 1,000 posts |
| An MCP client | Claude Code, Claude Desktop, Cursor, VS Code, Hermes Agent… |

Substack has no public API. The server calls the endpoints substack.com uses, with your session, so it sees only what you see logged in.

## Quick start

```bash
npm install -g substack-reader-mcp

substack-reader-mcp login     # a browser window opens; sign in to Substack as usual
substack-reader-mcp install   # registers the server with Claude Code
```

From source instead: `git clone https://github.com/wkbaran/substack-reader-mcp.git`, then `npm install && npm run build`, and use `node dist/cli.js` in place of `substack-reader-mcp`.

Restart Claude Code, then try:

- *"What's new in my Substack feed this week?"*
- *"Summarize the latest post from The Pragmatic Engineer."*
- *"Which of my saved posts did I never finish?"*
- *"What are people talking about in Nate's subscriber chat?"*
- *"Propose an interests.md from my Substack activity."*

<details>
<summary><b>Other MCP clients</b> (Claude Desktop, Cursor, VS Code)</summary>

Point the client at the `substack-reader-mcp` command (or `node` with the absolute path of `dist/cli.js` for a source checkout), after running `login`:

```json
{ "mcpServers": { "substack-reader": { "command": "substack-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 substack-reader -- substack-reader-mcp`
</details>

## Tools

How to name things:

- **Publications:** a name, part of a name (*"pragmatic"*), subdomain or URL.
- **Posts:** any Substack link, or a post id.

**Reading** (all read-only)

| Tool | What it returns |
|---|---|
| `get_feed` | Recent posts across all subscriptions, newest first. `since` takes `"7d"`, `"48h"` or a date |
| `read_post` | A full post as Markdown. Paid posts you can't access are flagged as previews |
| `get_recent_posts` · `search_posts` | One publication's latest posts · keyword search within one publication |
| `list_subscriptions` | Every subscription, including hidden ones, with membership |
| `get_reading_history` | Posts you opened on the web or in the app, with **how far you read each one**, and whether you hearted or saved it |
| `get_saved_posts` · `get_liked_posts` | Posts you saved · posts you hearted |
| `interests_evidence` | What your activity says about your taste, for [drafting an interests.md](docs/classifier.md#proposing-an-interestsmd-from-your-activity) |
| `auth_status` | Who you're logged in as, and whether the session works |

**Chat** (read-only; Substack's unread flags aren't reliable, so these work by activity time)

| Tool | What it returns |
|---|---|
| `list_chats` | Publication chats you're in, and direct messages |
| `get_chat_activity` | Chats and DMs with activity since a time, optionally with the new messages |
| `get_chat_threads` · `read_chat_thread` · `read_dm` | A chat's threads · one thread with its replies · one DM conversation |

**Account changes** (clients ask first; the server re-checks with Substack afterwards)

| Tool | What it does |
|---|---|
| `subscribe` | Free-subscribes you. Never starts a paid plan |
| `unsubscribe` | Removes a **free** subscription. Anything with a payment attached is refused |

**Digest** (only when `SUBSTACK_DIGEST_DIR` is set; plain-text output for agent harnesses)

| Tool | What it does |
|---|---|
| `digest_begin` | Fetches every new post and chat activity, and returns a work list with refs (`P1`, `P2`, …). The same list is also in `structuredContent`, for clients that run the digest from code |
| `digest_finish` | Takes a verdict per post and chat, 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](docs/digest-tools.md).

## Daily digest

The digest tools turn this server into a morning digest. Two ways to run it:

- **[substack-digest](https://github.com/wkbaran/substack-digest)**, a plain scheduled job (Docker and supercronic) that drives this server over MCP, asks an OpenAI-compatible gateway narrow questions and posts to a Discord webhook. This is how I run it.
- **[Hermes Agent](https://github.com/NousResearch/hermes-agent)**, which runs skills on a schedule and delivers to Discord and other chats, with the skill in [`hermes/SKILL.md`](hermes/SKILL.md). The setup is below.

Either way:

- **What it covers:** every new post in your subscriptions, plus chats and DMs with new activity.
- **How it reads:** every post is read in full (by subagents, under Hermes).
- **What you get:** one message, with:
  - picks worth reading in full, each with a summary and a reason
  - everything else, grouped by publication
  - chats, with anything addressed to you first
- **What the model does:** only the judging. Fetching, retries, state and layout are code, so the whole digest can run on a local 27B model. State files and locks use [job-ledger](https://www.npmjs.com/package/job-ledger).

### Setup with Hermes Agent

1. **Build** (`npm ci && npm run build`) and copy `dist/`, `package.json` and `package-lock.json` to where Hermes can see them, e.g. `/opt/data/mcp/substack-reader-mcp`. Then run `npm ci --omit=dev --omit=optional` there.
2. **Log in** on a machine with a browser (`substack-reader-mcp login`), then copy `~/.config/substack-reader/auth.json` to the Hermes host, e.g. `/opt/data/mcp/substack-reader-home/`.
3. **Register the server** in Hermes's `config.yaml`:
   ```yaml
   mcp_servers:
     substack-reader:
       command: node
       args: ["/opt/data/mcp/substack-reader-mcp/dist/cli.js"]
       env:
         SUBSTACK_READER_HOME: /opt/data/mcp/substack-reader-home
         SUBSTACK_DIGEST_DIR: /opt/data/sandbox/substack_digest   # absolute; created if needed
         SUBSTACK_DIGEST_TZ: America/New_York
         # SUBSTACK_CLASSIFIER: jev                               # optional; see below
         # SUBSTACK_JEV_API_KEY: ${JEV_OPENROUTER_API_KEY}        # from Hermes's .env
         # SUBSTACK_JEV_URL: https://openrouter.ai/api/alpha/decisions   # the default; any decisions-API endpoint
   ```
4. **Install the skill:** copy `hermes/SKILL.md` to `skills/productivity/substack-digest/` and `hermes/substack_digest_start.sh` to `scripts/`. Then edit the skill's Settings block. Optionally add an `interests.md` to the digest directory (from `hermes/interests.example.md`, or [drafted from your activity](docs/classifier.md#proposing-an-interestsmd-from-your-activity)).
5. **Schedule it** (run as the `hermes` user). The job needs only the `delegation` toolset and this server:
   ```bash
   hermes cron create "0 7 * * *" "Run the substack-digest skill and deliver the digest." \
     --name substack-digest --skill substack-digest --script substack_digest_start.sh --deliver discord:<channel-id>
   ```

More detail is in **[docs/digest.md](docs/digest.md)**: why the server has harness-specific tools, customizing, state, rate limits, chats and cost.

## Headline ranking with Jev

> **Optional here, and off by default.**
> - **Your Substack feed is only what you subscribe to,** so it's already curated, and the digest can afford to read every post.
> - **This classifier was built for [medium-reader-mcp](https://github.com/wkbaran/medium-reader-mcp),** where the feed is effectively endless (100–200 new posts a day from writers you never chose), and was carried over here.

What it still adds on Substack:

- **[Jev](docs/jev/README.md)** is a "decision model" from TypeSafe. It returns typed answers with probabilities, not text. Before anything is read, it gives every post:
  - a **rank** (how much you'd want it, judged against `interests.md`)
  - a **skip** probability (whether it matches your Skip list)
- **In the digest:**
  - Posts are read best-first, and the rank informs the "Read in full" picks.
  - Confident skips (say, podcast show notes) aren't read at all.
  - With `SUBSTACK_DIGEST_RANK_FLOOR`, low-ranked posts become optional, which helps if your subscription list grows.
- **Backends** (`SUBSTACK_CLASSIFIER`):
  - **`off`** (default).
  - **`jev`**: ranks and skips. The server calls the decisions API itself over HTTPS, not through sampling.
  - **`sampling`**: 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. It doesn't rank, so ordering is left to the agent's model.
- **Any Jev-style provider:** the `jev` backend speaks the decisions API (POST `model`, `state`, `questions` → `answers`), and you point it at a provider the way you'd point an OpenAI-compatible client at a local model:
  - `SUBSTACK_JEV_URL`: the endpoint. Default: OpenRouter's `https://openrouter.ai/api/alpha/decisions`. TypeSafe's System One API (`…/v1/systemone`) and compatible or self-hosted servers work too.
  - `SUBSTACK_JEV_API_KEY`: the bearer token (falls back to `OPENROUTER_API_KEY`). It can be empty for a server without auth.
  - `SUBSTACK_JEV_MODEL`: the model id. Default `typesafe/jev-1.13`; TypeSafe's own API uses `jev-1.13`.
- **Tune it to you:**
  - Label your own posts with one keypress each, and the tools recommend thresholds and edits to `interests.md`.
  - The tools can also draft an `interests.md` from what you pay for, save, heart, finish and dismiss.

Everything about it is in **[docs/classifier.md](docs/classifier.md)**.

## Logging in

- **Browser (default):** `login` opens Chrome or Edge on Substack's sign-in page. The session is saved as soon as you're in and lasts about three months. Re-running `login` usually needs no typing.
- **Paste:** `login --paste` (or `--stdin`) takes the `substack.sid` cookie in any format: the bare value, a `Cookie:` header, a Cookie-Editor export, or `cookies.txt`.
- **No restart needed:** the server re-reads the session on every call.
- **Tips:**
  - If Substack emails you a sign-in link, paste it into the `login` window.
  - Google sign-in sometimes refuses an automated window; use email or password instead.

| Command | What it does |
|---|---|
| `substack-reader-mcp login` | Log in (`--paste` / `--stdin` to paste the cookie) |
| `substack-reader-mcp status` | The logged-in account, subscription count, and whether the session works |
| `substack-reader-mcp logout` | Delete the session (`--all` also deletes the login browser profile) |
| `substack-reader-mcp install` | Register with Claude Code (`--scope user\|project\|local`) |

<details>
<summary><b>Environment variables</b></summary>

| Variable | Purpose |
|---|---|
| `SUBSTACK_SID`, `SUBSTACK_COOKIE` | Session cookie, overriding the saved session (containers, CI) |
| `SUBSTACK_READER_HOME` | Config directory (default `~/.config/substack-reader`) |
| `SUBSTACK_BROWSER_PATH` | A Chromium-based browser for `login` |
| `SUBSTACK_USERNAME` | When logged out, `list_subscriptions` shows this user's public subscriptions |
| `SUBSTACK_COOKIES_PATH` | A Cookie-Editor export from the original Python version, read as a fallback |
| `SUBSTACK_DIGEST_DIR` | Absolute path of the digest directory; setting it turns on the digest tools |
| `SUBSTACK_DIGEST_TZ` | Time zone for digest and chat times (default `TZ`, else UTC) |
| `SUBSTACK_CLASSIFIER` | `off` (default), `jev` or `sampling` |
| `SUBSTACK_JEV_URL` | Decisions-API endpoint for `jev` (default OpenRouter's) |
| `SUBSTACK_JEV_API_KEY` | Bearer token for that endpoint (default `OPENROUTER_API_KEY`) |
| `SUBSTACK_JEV_MODEL` | Model id (default `typesafe/jev-1.13`) |
| `SUBSTACK_DIGEST_SKIP_THRESHOLD` | Skip probability at which a post is set aside (default 0.7) |
| `SUBSTACK_DIGEST_RANK_FLOOR` | Rank below which reading a post is optional (default off) |
| `SUBSTACK_DIGEST_FETCH_BUDGET` | How long `digest_begin` may spend fetching (default `250s`, which fits a 300 s agent tool timeout). Raise it where the client waits longer, e.g. `10min` |
| `SUBSTACK_DIGEST_GIST_MAX`, `_WHY_MAX`, `_TOPICS_MAX`, `_FOR_USER_MAX` | Longest gist, why line, chat topics and chat question for you, in characters (default `500`, `300`, `500`, `300`). 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 |
| `SUBSTACK_RATE`, `SUBSTACK_CONCURRENCY`, `SUBSTACK_MIN_GAP` | Pacing of requests to Substack, e.g. `60/min`, `2`, `250ms` (default: no rate cap; the digest fetches 3 publications at a time). See [paced-fetch](https://github.com/wkbaran/paced-fetch) |
| `SUBSTACK_RETRIES`, `SUBSTACK_MAX_RETRY_WAIT` | Retries after a 429 (or a 5xx or network error on a GET), waiting 2 s, 5 s, then 10 s or Substack's `Retry-After`; and the longest `Retry-After` worth waiting (default `3`, `60s`; longer fails at once) |

</details>

## Privacy and security

- **Your session goes only to `substack.com` and `*.substack.com`.** Custom-domain publications get their own session through Substack's sign-in handoff, as in a browser. Redirects are followed one hop at a time, so no cookie reaches another host.
- **It's stored in `~/.config/substack-reader/auth.json`**, mode `600`. Nothing is stored in the repo.
- **No telemetry.** Requests go only to Substack, to the publications you ask about, and to your Jev provider if it's on (headlines and your interests only).
- **Only `subscribe` and `unsubscribe` change anything,** and only free subscriptions.
- **Reading activity is read, never changed.** The endpoints that mark posts as seen or saved are never called.
- **`package-lock.json` pins every dependency;** install with `npm ci`.

## Troubleshooting

| Symptom | Fix |
|---|---|
| *"Not logged in"* / *"session was rejected"* | `substack-reader-mcp login` (sessions last about three months) |
| *"Only a preview was returned"* | No paid subscription to that publication, or the session expired; `status` checks |
| The server doesn't appear in `/mcp` | Restart Claude Code |
| `login` can't find a browser | Install Chrome, set `SUBSTACK_BROWSER_PATH`, or use `--paste` |
| *"doesn't look like a Substack publication"* | The newsletter may have left Substack |
| The digest says `CLASSIFIER: … unavailable` | For `jev`, check `SUBSTACK_JEV_URL` and the key; for `sampling`, the client's sampling setup |

## Development

```bash
npm ci && npm test     # vitest against a fake fetch; no network needed
npm run typecheck && npm run build
```

- **`src/substack/`**: the HTTP client, posts, subscriptions, reader shelves and chat.
- **`src/digest/`**: the digest tools.
- **`src/classifier/`**: the headline classifier, shared with [medium-reader-mcp](https://github.com/wkbaran/medium-reader-mcp).
- **`tools/classifier/`**: label, score, analyze, propose.
- **[CLAUDE.md](CLAUDE.md)**: how Substack's endpoints actually behave, including look-alikes that write. Read it before changing `src/substack/`.

## Disclaimer

An independent project, not affiliated with or endorsed by Substack or TypeSafe. It uses Substack's undocumented web endpoints, which can change without notice. Use it with your own account, within Substack's [Terms of Use](https://substack.com/tos).

## License

[MIT](LICENSE) © 2026 Bill Baran.

TDQS

A3.8/5.0

Scored across 12 tools

Disambiguation4/5

Most tools map to distinct resources and actions: subscription management, feed reading, post retrieval, and chat/DMs. The only real confusion risk is get_feed versus get_recent_posts, though their descriptions clearly separate 'all subscriptions' from 'one publication.'

Naming Consistency4/5

The naming convention is largely verb_noun: list_subscriptions, get_feed, search_posts, read_post, subscribe, unsubscribe. Minor deviations such as auth_status instead of get_auth_status and the abbreviated read_dm prevent a perfect score.

Tool Count5/5

Twelve tools is well within the ideal range and each tool covers a distinct reader workflow: authentication, subscription management, post discovery/reading, and chat/DMs. No tool feels redundant or unnecessary.

Completeness5/5

The toolset covers the full Substack reader lifecycle: auth status, listing/subscribing/unsubscribing, feed and publication browsing, archive search, full post reading, and chat/DM threads. There are no obvious dead ends for the stated reader-focused purpose.

Maintenance

ActivityMaintained
ResponsivenessNo recent community issues