Skip to main content
Glama
substack-saved-mcp

substack-saved-mcp

by toniher
README.md
# Substack Saved Posts & Notes MCP & CLI

[![PyPI version](https://img.shields.io/pypi/v/substack-saved-mcp.svg)](https://pypi.org/project/substack-saved-mcp/)

A local stdio Model Context Protocol (MCP) server and sync engine for the Substack **posts and notes** you have saved.

> **Behavior change in 0.4.0:** a plain `substack-saved-mcp sync` now syncs your **reading history** too: every post you opened on Substack, saved or not. Run `sync --no-history` to sync only saved posts and notes as before. Unsaved posts from your history stay out of default `list`/`search` results.

## Features

- **Read & Search**: Full-text search (SQLite FTS5) over saved post titles, excerpts, authors, and publications. Filter by publication, audience tier (`everyone`, `only_paid`), reading progress, and date ranges (`published_at` or `saved_at`). Search covers a post's full body text only after you fetch it once with `get-content` or the `get_post_content` tool. A normal `sync` stores metadata and excerpts, so search matches posts you haven't opened on their title, excerpt, and metadata.
- **Reading Progress**: Substack records how far you've read each saved post. Its mobile apps show this; the web UI doesn't. This tool stores it, so you can list the posts you haven't read or haven't finished, and sort by the minutes left. See [Which saved posts haven't I read?](#which-saved-posts-havent-i-read).
- **Reading History**: `sync` also pulls Substack's inbox History tab: every post you opened, saved or not, with when you last viewed it (`seen_at`) and your reading progress. Browsed-but-unsaved posts are cached with `is_saved = 0`, so default `list`/`search` results stay saved-only; add `--history` (MCP: `history=True`) to browse or search what you've read.
- **Saved Notes**: The tool syncs, searches, and caches Substack's short-form notes apart from posts. A note has an author and a body instead of a title or audience tier. Search covers note bodies, authors, and the titles of restacked posts. Notes skip the browser: sync, save, unsave, and content fetch are all plain authenticated API calls.
- **Full Content for LLMs**: Fetch a saved post or note and get its content back as clean text, with headings, lists, and links kept readable. The tool caches the result for next time.
- **Save & Unsave**: Bookmark posts and notes, or remove bookmarks. Posts use a headless browser session; notes use the API.
- **Offline Queries**: Read and search tools query the local SQLite cache, with no network calls.
- **Local Credentials**: Your Substack session stays on your machine, in a data directory only your user can read.
- **MCP Tools and Resources** for both posts and notes over stdio.

---

## Installation with `uv`

Install and run `substack-saved-mcp` with [`uv`](https://github.com/astral-sh/uv).

### Option A: Install from PyPI as a system-wide tool (`uv tool install`)

Install the published package from [PyPI](https://pypi.org/project/substack-saved-mcp/):

```bash
# Install system-wide into an isolated uv environment
uv tool install substack-saved-mcp
```

To install from a local checkout instead:

```bash
# Navigate to the repository
cd /path/to/substack-saved-mcp

# Install system-wide into an isolated uv environment
uv tool install .

# Or install from the Git repository:
# uv tool install git+https://github.com/toniher/substack-saved-mcp.git
```

The install puts `substack-saved-mcp` on your PATH:

```bash
# Verify installation
substack-saved-mcp --help
```

To update or uninstall:
```bash
# Upgrade installed tool
uv tool upgrade substack-saved-mcp

# Uninstall tool
uv tool uninstall substack-saved-mcp
```

---

### Option B: Development environment (`uv sync`)

To develop or modify the code:

```bash
# Clone and enter directory
cd substack-saved-mcp

# Install dependencies and dev tools (pytest)
uv sync --extra dev

# Run CLI commands using uv run
uv run substack-saved-mcp --help

# Run tests
uv run pytest
```

---

## Quick Start

```bash
# 1. Initialize local database
substack-saved-mcp init

# 2. Authenticate with Substack (opens interactive browser window once)
substack-saved-mcp login

# 3. Sync saved posts, saved notes, AND reading history (all three by default;
#    add --no-history to skip history)
substack-saved-mcp sync

# 3b. Or sync just one entity
substack-saved-mcp sync --only posts
substack-saved-mcp sync --only notes
substack-saved-mcp sync --only history
substack-saved-mcp sync --no-history   # saved posts and notes, skip history

# 4. Search saved posts via CLI
substack-saved-mcp search "artificial intelligence"

# 4b. Filter by publication or audience tier (see which tiers are cached with `audiences`)
substack-saved-mcp audiences
substack-saved-mcp list --audience only_paid
substack-saved-mcp search "artificial intelligence" --audience everyone

# 4c. Find posts you haven't read or haven't finished
substack-saved-mcp list --read-state unread
substack-saved-mcp list --read-state unfinished --sort-by minutes_remaining

# 4d. Browse or search your reading history (viewed posts, saved or not)
substack-saved-mcp list --history
substack-saved-mcp search "democracy" --history
substack-saved-mcp search "artificial intelligence" --read-state unread

# 5. Save or unsave a post
substack-saved-mcp save "https://example.substack.com/p/post-slug"
substack-saved-mcp unsave "https://example.substack.com/p/post-slug"

# 6. Get a saved post's full content, cleaned up and ready for an LLM
substack-saved-mcp get-content "https://example.substack.com/p/post-slug"

# 7. Work with saved notes the same way
substack-saved-mcp list-notes --limit 10
substack-saved-mcp search-notes "kubernetes" --author alice
substack-saved-mcp note-authors
substack-saved-mcp save-note "https://substack.com/@handle/note/c-123456"
substack-saved-mcp unsave-note "https://substack.com/@handle/note/c-123456"
substack-saved-mcp get-note "https://substack.com/@handle/note/c-123456"

# 8. Check combined status (counts, unread totals, last sync per entity)
substack-saved-mcp status

# 9. Launch stdio MCP server
substack-saved-mcp serve
```

---

## Configuring MCP Clients (Claude Desktop, Goose, Cursor, etc.)

Add `substack-saved-mcp` to your MCP client's configuration file (e.g. `claude_desktop_config.json`).

### Using the installed tool (`uv tool` or global binary)

```json
{
  "mcpServers": {
    "substack-saved": {
      "command": "substack-saved-mcp",
      "args": ["serve"]
    }
  }
}
```

### Using `uv` from the repository path

To run from your checkout without installing:

```json
{
  "mcpServers": {
    "substack-saved": {
      "command": "uv",
      "args": [
        "--directory",
        "/path/to/substack-saved-mcp",
        "run",
        "substack-saved-mcp",
        "serve"
      ]
    }
  }
}
```

---

## Frequently Asked Questions (FAQ)

### Where is the database saved?

The SQLite database lives in your OS application data directory:

- **Linux / macOS**: `~/.local/share/substack-saved-mcp/saved_posts.sqlite`  
  *(or `$XDG_DATA_HOME/substack-saved-mcp/saved_posts.sqlite` if you set `XDG_DATA_HOME`)*

Set a custom database path or directory with environment variables:
```bash
export SUBSTACK_SAVED_DB_PATH="/path/to/my/custom_database.sqlite"
# or
export SUBSTACK_SAVED_DATA_DIR="/path/to/my/data_dir"
```

### Which saved posts haven't I read?

Filter by reading state on the CLI with `--read-state`, or pass `read_state` to the `list_saved_posts` and `search_saved_posts` MCP tools. In a chat, ask your assistant to "list my unread saved posts".

| State | Posts it matches |
|-------|------------------|
| `unread` | You never opened them, and Substack recorded no progress. |
| `in_progress` | You read part, below the finished threshold. |
| `finished` | You read past the threshold (default 95%). |
| `unfinished` | Everything below the threshold: `unread` plus `in_progress`. |
| `started` | You opened them or read any part. |

```bash
substack-saved-mcp sync --force                      # refresh progress for every post first
substack-saved-mcp list --read-state unfinished --sort-by minutes_remaining --limit 20
substack-saved-mcp status                            # unread count and total minutes remaining
```

Sorting `unfinished` posts by `minutes_remaining` puts the quickest ones to finish at the top. Each post also reports `is_fully_read` and `minutes_remaining`. Notes have no reading progress.

### How does the tool decide a post is fully read, and how fresh is that?

Substack reports a `max_read_progress` high-water mark (0.0 to 1.0) for each saved post. The tool counts a post as fully read once that value reaches a threshold, `0.95` by default, because finished posts on Substack tend to stop around `0.98` to `0.9999` rather than at `1.0`. Change it with:
```bash
export SUBSTACK_SAVED_FULLY_READ_THRESHOLD="0.90"
```
A sync refreshes progress for each post it fetches. An incremental sync stops at the first posts it already has, so progress on older posts goes stale until you run `substack-saved-mcp sync --force`. A post you finished last week can show as `unread` until then.

### Which Substack API does syncing saved posts use?

Sync tries Substack's unified reader API first. If that fails, it falls back to an older posts-only API, then to headless browser scraping. To force one source while troubleshooting, set:
```bash
export SUBSTACK_SAVED_POSTS_SOURCE="unified"  # or "legacy" or "dom"
```
Leave it unset, or set `"auto"`, for the default fallback chain.

### What does a "partial" sync status mean?

Substack sometimes rate-limits a sync (HTTP 429) so hard that a page of results fails even after retries. The sync then keeps what it fetched and reports `status: partial` instead of `success`, in `substack-saved-mcp status` and in the sync tool's response. On a `sync --force`, a partial run skips reconciliation, so the tool won't mark a post or note as unsaved because it couldn't fetch it. Run `sync` again later to pick up what it missed.

### Will a browser window pop up when running as an MCP server?

No. During normal MCP use you won't see a browser window.

- **Read & Search Tools** (`search_saved_posts`, `list_saved_posts`, `get_saved_post`, `search_saved_notes`, `list_saved_notes`, `get_saved_note`, `list_publications`, `list_audiences`, `saved_posts_status`):  
  Query the local SQLite database offline. No browser.
- **Post Sync & Write Tools** (`sync_saved_posts`, `sync_reading_history`, `save_post`, `unsave_post`, `get_post_content`):  
  Run headless with the session stored in `storage_state.json`.
- **Note Sync & Write Tools** (`sync_saved_notes`, `save_note`, `unsave_note`, `get_note_content`):  
  Make direct API requests with `storage_state.json`, with no browser page, headless or visible.
- **Interactive Login**:  
  A browser window opens only when you run `substack-saved-mcp login` in your terminal. If your session expires while you use an MCP client, the tool returns an error telling you to run `substack-saved-mcp login`.

### What if I get a Playwright "Executable doesn't exist" error?

An error like `BrowserType.launch: Executable doesn't exist` (often during `login`) means Playwright hasn't installed its browsers in the tool's isolated environment. Run `playwright install` inside that environment.

For a system-wide install (`uv tool install`):
```bash
~/.local/share/uv/tools/substack-saved-mcp/bin/playwright install
```

For a development environment (`uv sync`):
```bash
uv run playwright install
```

### I edited the source code, but the installed `substack-saved-mcp` command still runs the old version. Why?

`uv tool install` copies the package into its own environment at install time and doesn't track your working tree. After you edit files under `src/` or pull new commits, the installed copy keeps running the old code. `uv run substack-saved-mcp ...` from the repo uses your current source.

Reinstall from your working tree to pick up the changes:
```bash
uv tool install . --no-cache --force
```
- `--force` replaces the installed version instead of skipping because one is present.
- `--no-cache` builds fresh instead of reusing a cached wheel from before your edits.

TDQS

A4/5.0

Scored across 18 tools

Disambiguation4/5

The tool set is mostly clearly split by resource (posts vs. notes) and action (save, unsave, list, search, sync, content). The main ambiguity is between content-fetching tools like get_post_content/get_note_content and cached-detail tools like get_saved_post/get_saved_note, which overlap somewhat in returning note/post bodies. The history flag on list/search saved posts adds a secondary mode but remains distinguishable from sync_reading_history.

Naming Consistency4/5

Most tools follow a predictable snake_case verb_noun pattern: save_post, unsave_note, list_saved_posts, search_saved_notes, sync_reading_history. Minor deviations like saved_posts_status and the get_*_content vs get_saved_* distinction keep it from being perfectly uniform, but the convention is still easy to follow.

Tool Count4/5

18 tools is slightly on the heavy side, but the server manages two related resource types (posts and notes) plus sync, search, listing, content retrieval, and status operations. Each tool maps to a fairly specific operation, so the count is reasonable rather than padded.

Completeness5/5

The surface covers the full saved-item lifecycle for both posts and notes: save, unsave, get cached details, fetch content, list, search, and sync. It also includes publication/audience discovery, cache/sync status, and reading-history sync, leaving no obvious dead ends for the stated purpose.

Maintenance

ActivityMaintained
ResponsivenessNo recent community issues