Metadata-Version: 2.5
Name: substack-saved-mcp
Version: 0.3.3
Summary: Local stdio-based MCP server and sync engine for Substack saved posts
Author-email: Toni Hermoso Pulido <toniher@cau.cat>
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: click>=8.1.0
Requires-Dist: fastmcp>=0.1.0
Requires-Dist: playwright>=1.40.0
Requires-Dist: pydantic>=2.0.0
Provides-Extra: dev
Requires-Dist: pytest>=8.0.0; extra == 'dev'
Requires-Dist: ruff==0.16.1; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

## 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).
- **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 AND notes into local cache (both entities by default)
substack-saved-mcp sync

# 3b. Or sync just one entity
substack-saved-mcp sync --only posts
substack-saved-mcp sync --only notes

# 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
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`, `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.
