substack-saved-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. substack_saved_mcp-0.1.0/.github/workflows/ci.yml +32 -0
  2. substack_saved_mcp-0.1.0/.github/workflows/pypi.yml +61 -0
  3. substack_saved_mcp-0.1.0/.gitignore +19 -0
  4. substack_saved_mcp-0.1.0/.pre-commit-config.yaml +6 -0
  5. substack_saved_mcp-0.1.0/CLAUDE.md +43 -0
  6. substack_saved_mcp-0.1.0/LICENSE +21 -0
  7. substack_saved_mcp-0.1.0/PKG-INFO +218 -0
  8. substack_saved_mcp-0.1.0/PLAN.md +232 -0
  9. substack_saved_mcp-0.1.0/README.md +200 -0
  10. substack_saved_mcp-0.1.0/pyproject.toml +42 -0
  11. substack_saved_mcp-0.1.0/src/substack_saved_mcp/__init__.py +3 -0
  12. substack_saved_mcp-0.1.0/src/substack_saved_mcp/cli.py +413 -0
  13. substack_saved_mcp-0.1.0/src/substack_saved_mcp/config.py +55 -0
  14. substack_saved_mcp-0.1.0/src/substack_saved_mcp/content_utils.py +151 -0
  15. substack_saved_mcp-0.1.0/src/substack_saved_mcp/database.py +550 -0
  16. substack_saved_mcp-0.1.0/src/substack_saved_mcp/mcp_server.py +307 -0
  17. substack_saved_mcp-0.1.0/src/substack_saved_mcp/models.py +92 -0
  18. substack_saved_mcp-0.1.0/src/substack_saved_mcp/substack_client.py +718 -0
  19. substack_saved_mcp-0.1.0/src/substack_saved_mcp/sync.py +267 -0
  20. substack_saved_mcp-0.1.0/src/substack_saved_mcp/url_utils.py +55 -0
  21. substack_saved_mcp-0.1.0/tests/test_cli.py +213 -0
  22. substack_saved_mcp-0.1.0/tests/test_content_utils.py +69 -0
  23. substack_saved_mcp-0.1.0/tests/test_database.py +340 -0
  24. substack_saved_mcp-0.1.0/tests/test_mcp_server.py +225 -0
  25. substack_saved_mcp-0.1.0/tests/test_normalization.py +134 -0
  26. substack_saved_mcp-0.1.0/tests/test_substack_client.py +339 -0
  27. substack_saved_mcp-0.1.0/tests/test_sync.py +514 -0
  28. substack_saved_mcp-0.1.0/tests/test_url_utils.py +20 -0
  29. substack_saved_mcp-0.1.0/uv.lock +1703 -0
@@ -0,0 +1,32 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ quality:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python-version: ["3.11", "3.12"]
17
+
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ enable-cache: true
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - run: uv sync --extra dev --locked
30
+ - run: uvx prek@0.4.11 run --all-files
31
+ - run: uv run pytest
32
+ - run: uv build
@@ -0,0 +1,61 @@
1
+ # Upload a Python Package using Twine when a release is created
2
+ # Adapted from mne-bids-pipeline
3
+ # From https://github.com/sphinx-gallery/sphinx-gallery/blob/master/.github/workflows/release.yml
4
+
5
+ name: Build package
6
+ on: # yamllint disable-line rule:truthy
7
+ release:
8
+ types: [published]
9
+ push:
10
+ branches:
11
+ - main
12
+ pull_request:
13
+ branches:
14
+ - main
15
+
16
+ permissions:
17
+ contents: read
18
+ id-token: write
19
+
20
+ jobs:
21
+ package:
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - uses: actions/checkout@v5
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version: '3.12'
29
+ - name: Install dependencies
30
+ run: |
31
+ python -m pip install --upgrade pip
32
+ pip install build twine
33
+ - name: Build package
34
+ run: python -m build --sdist --wheel
35
+ - name: Check package
36
+ run: twine check --strict dist/*
37
+ - name: Check env vars
38
+ run: |
39
+ echo "Triggered by: ${{ github.event_name }}"
40
+ - uses: actions/upload-artifact@v4
41
+ with:
42
+ name: dist
43
+ path: dist
44
+
45
+ # PyPI on release
46
+ pypi:
47
+ needs: package
48
+ runs-on: ubuntu-latest
49
+ if: github.event_name == 'release'
50
+ environment:
51
+ name: pypi
52
+ steps:
53
+ - uses: actions/download-artifact@v5
54
+ with:
55
+ name: dist
56
+ path: dist
57
+ - name: Publish to PyPI
58
+ uses: pypa/gh-action-pypi-publish@release/v1
59
+ with:
60
+ user: __token__
61
+ password: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,19 @@
1
+ # Python & environments
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *$py.class
6
+ *.egg-info/
7
+ dist/
8
+ build/
9
+ .pytest_cache/
10
+
11
+ # Application Data & Credentials (DO NOT COMMIT)
12
+ data/
13
+ browser_state/
14
+ browser-state/
15
+ storage_state.json
16
+ *.sqlite
17
+ *.sqlite-wal
18
+ *.sqlite-shm
19
+ .env
@@ -0,0 +1,6 @@
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.16.1
4
+ hooks:
5
+ - id: ruff-check
6
+ - id: ruff-format
@@ -0,0 +1,43 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code when working with this repository.
4
+
5
+ ## Commands
6
+
7
+ Use `uv` for all development commands. Set up the environment with:
8
+
9
+ ```bash
10
+ uv sync --extra dev
11
+ ```
12
+
13
+ | Task | Command |
14
+ | --- | --- |
15
+ | Run all tests | `uv run python -m pytest` |
16
+ | Run one test file | `uv run python -m pytest tests/test_database.py` |
17
+ | Run one test | `uv run python -m pytest tests/test_database.py::test_fts5_search` |
18
+ | Build the package | `uv build` |
19
+ | Run the CLI | `uv run substack-saved-mcp --help` |
20
+ | Reinstall tool globally | `uv tool install . --no-cache --force` |
21
+
22
+ `ruff` is configured in `pyproject.toml` (rules: `E4`, `E7`, `E9`, `F`, `I`, `UP`, `B`, `RUF`, line length 88, target py311). Lint with `uvx ruff check .` or `uv run ruff check .`.
23
+
24
+ ## Architecture
25
+
26
+ - `cli.py` is the Click entry point (`substack-saved-mcp`). Its commands initialize the database and then delegate to the repository, sync engine, Playwright client, or MCP server.
27
+ - `mcp_server.py` exposes the same application operations as FastMCP stdio tools and resources. Read tools query the local cache; sync and save/unsave operations use the authenticated browser session.
28
+ - `substack_client.py` owns Playwright authentication and remote Substack interaction. `login` is the only intended headful workflow; normal sync and write paths use `storage_state.json` headlessly. Synchronous Playwright API calls are routed through `_run_playwright_sync()` to safely execute in a worker thread if an `asyncio` event loop is active (e.g. under FastMCP). Saved-post fetching prefers the reader inbox API (`GET /api/v1/reader/posts?inboxType=saved`), which exposes the real bookmark timestamp (`saved_at`) and an ISO publication date (`post_date`) per post. `_fetch_all_saved_via_reader_api()` cursor-paginates that endpoint (each page's oldest `saved_at` becomes the next `after=` cursor, until `more` is false), dedupes by canonical URL, and enriches each flat post with its `publication` object (from the response's `publications` array, matched by `publication_id`) and `author_name` (from `publishedBylines`). The full result is cached in `_api_cache` and sliced by offset. Each page request goes through `_reader_api_get()`, which retries transient failures (HTTP 429 and 5xx) up to `max_retries` (default 3) with `Retry-After`-aware backoff: `_retry_after_seconds()` honors an integer `Retry-After` header (clamped to a 30s cap so a hostile value can't hang the sync) and otherwise uses capped exponential backoff (0.5s, 1s, 2s, ...); 401/403 and other 4xx are returned unretried so the existing auth/fallback handling applies. A 429 that survives all retries is deliberately treated as "unavailable" (partial list, or `None` → DOM fallback) rather than as a silent empty-success, so rate-limiting never masquerades as "you have no saved posts." Both `_reader_api_get()` and `_fetch_all_saved_via_reader_api()` accept an injectable `sleep_func` (default `time.sleep`) so tests exercise the backoff without real delays. If that endpoint is unavailable, it falls back to headless DOM extraction on `https://substack.com/saved`, which scrapes `div.reader2-post-container` cards, caches scrolling results in `_dom_cache`, and marks each dict with `_dom: True`; DOM cards only expose a localized relative publish string and no bookmark time, so `saved_at` stays unknown on that path. Both `save_post()` and `unsave_post()` were originally guesswork (an unverified DOM button selector plus an unverified `POST /api/v1/bookmark` call) and were fixed once real endpoints were captured via `inspect-network`. Every post page server-renders a `window._preloads` blob containing that post's own numeric ID and rich metadata (`preloads.post.id`, `.title`, `.audience`, `.description`/`.subtitle`, `.post_date`, plus `preloads.pub.name`); `_save_post_impl` reads this via `page.evaluate("() => window._preloads")` right after page load and, when an ID is found, calls the real `POST https://substack.com/api/v1/posts/saved` endpoint (body `{"post_id": ...}`) directly via the Playwright API request context — this also lets it populate the returned `SavedPost` with accurate title/publication/audience/excerpt instead of parsing `page.title()`. `_unsave_post_impl` does the mirror image: when the post's `substack_post_id` is already known (true for any post that has been through a normal `sync`, since the reader API's `id` field populates it), it calls `DELETE https://substack.com/api/v1/posts/saved` with the same body shape, no DOM interaction at all. Both treat an `ok` response as `"confirmed"` and only fall back to the old best-effort DOM click (`_click_bookmark_toggle()`, used when the numeric ID can't be obtained or the direct call doesn't confirm) — note its selector is English-only (`aria-label*='save'/'bookmark'`) and can silently fail on non-English Substack UIs, which is part of why the direct API path is preferred whenever possible. `_click_bookmark_toggle()` fingerprints the button's `aria-label`/`aria-pressed`/`class` before and after the click and returns `"confirmed"` only if that fingerprint changed, else `"unconfirmed"`, `"not_found"`, or `"click_failed"` — `save_post()` returns `(SavedPost, confirmation)` and `unsave_post()` returns just the confirmation string; both accept an optional `playwright_instance` for test injection (same pattern as `_fetch_via_dom`). `fetch_post_content()` reuses the same `window._preloads` mechanism to retrieve a saved post's full content: `_fetch_post_content_impl` navigates to the post's page and reads `preloads.post.body_html` (Substack's field name for full content — `parse_remote_post()` already expects this key from the reader API, though the saved-list payload never actually populates it, only individual post pages do), returning `None` for `body_html` if the page's embed format doesn't expose it (frontend change) or the account lacks paywall access; this is the case where the caller should be told to run `inspect-network` against an open post page to re-discover the real content source. `cli.py`'s `inspect-network` command (which logs any `api/v1`/`bookmark`/`saved` request's method, URL, status, and JSON body while the user manually clicks around a headful browser) is the tool for discovering/re-verifying these endpoints when Substack's frontend changes.
29
+ - `content_utils.py` converts a post's raw `body_html` into clean text for LLM consumption. `html_to_llm_text()` is a small `html.parser.HTMLParser` subclass (no external HTML library dependency) that renders headings as markdown `#` prefixes, list items as `- ` bullets, links as `text (url)`, keeps minimal `**bold**`/`*italic*` markers, drops `script`/`style`/`iframe` content entirely, and collapses excess blank lines. `format_post_for_llm()` prepends a plain metadata header (title, publication, author, published date, URL) to the cleaned body text — this combined string is what both the CLI's `get-content` command and the MCP `get_post_content` tool return.
30
+ - `sync.py` converts API/DOM payloads to `SavedPost` models and coordinates paginated incremental or full syncs. `parse_remote_post()` routes DOM dicts (identified by the `_dom` marker) through a minimal mapping and everything else through the full reader-API/legacy mapping; it never fabricates a `saved_at` from the sync moment (unknown save times stay `None`). `word_count` is mapped defensively from several unconfirmed candidate keys (`wordcount`/`word_count`/`words`, via `_first_positive_int()` which ignores non-positive/uncoercible values) — the exact reader-API field name hasn't been captured, so if these stay empty an `inspect-network` capture is needed to find the real one. `reading_time_minutes` is intentionally *derived* (ceil of `word_count / WORDS_PER_MINUTE`, ~200 wpm) rather than mapped from a field, because a wrong guess about that field's unit (seconds vs minutes) would persist a badly wrong value; deriving is unit-unambiguous. `image_url` is mapped from `cover_image`/`image_url` on the reader-API payload and is populated for the large majority of synced posts (confirmed live: ~98%); it was previously stored but invisible through every read path except `get_saved_post`/the full `SavedPost` — `PostSummary` and the `list_posts()`/`search_posts()` SELECTs now include it too; the CLI `search` command prints it when present, but `list` deliberately omits it since it's a long, uninformative CDN URL in that terser view. `content_text` remains unpopulated by sync (only `get-content`/`get_post_content` populate it) because the saved-list payload doesn't carry post body HTML, only individual post pages do. There is no `metadata_json` column: it was a never-populated "raw source JSON" placeholder from the original PLAN and has been removed from the model and schema. Existing databases created before its removal may still have an inert `metadata_json` column; reads tolerate it because `SavedPost` (Pydantic) ignores unknown columns from `SELECT *`, and `upsert_post` no longer references it. A force/full sync (`--force`) collects every fetched post's URL and, after the fetch loop completes, calls `reconcile_unsaved_posts()` to soft-delete any locally `is_saved = 1` post absent from that complete remote set — this is how posts unsaved directly on Substack (outside this tool) get reflected locally. An incremental sync never reconciles, since its early-stop-on-matches optimization means it only sees a partial remote list. It records each run in SQLite (including `reconciled_count`) and returns a `SyncRun` with `success`, `auth_required`, or `failed` status instead of propagating expected authentication failures.
31
+ - `database.py` is the SQLite repository and schema owner. The `posts` table is the cache; `sync_runs` records sync history; `posts_fts` is an external-content FTS5 index maintained by database triggers. `reconcile_unsaved_posts()` bulk soft-deletes posts missing from a given complete remote URL set and is a no-op on an empty list (an empty list is more likely a fetch problem than genuine mass-unsaving). `init_db()` runs additive column migrations (e.g. `posts.audience`, `sync_runs.reconciled_count`) via `ALTER TABLE` guarded by an existence check or `try`/`except sqlite3.OperationalError`, since `CREATE TABLE IF NOT EXISTS` never adds columns to an already-existing table; the `posts.audience` check runs before the `executescript` block because the `idx_posts_audience` index creation inside it would otherwise fail on a pre-migration table. `list_posts()`/`search_posts()` accept an `audience` filter (exact match, case-insensitive), and `list_audiences()` returns the distinct audience values actually present in the cache with post counts, rather than hardcoding Substack's (undocumented, possibly-growing) audience enum. `PostSummary` (returned by `list_posts()`/`search_posts()` and the MCP list/search tools) carries `reading_time_minutes` and `word_count`, and the CLI `list`/`search` commands display them — without this the values were populated in the `posts` table but invisible through every read path except `get_saved_post`/the full `SavedPost`.
32
+ - `config.py` centralizes application paths. By default, data lives under `~/.local/share/substack-saved-mcp`; `SUBSTACK_SAVED_DB_PATH`, `SUBSTACK_SAVED_DATA_DIR`, and `SUBSTACK_SAVED_BROWSER_DIR` override those paths.
33
+
34
+ ## Repository Conventions
35
+
36
+ - Model persisted posts with the Pydantic `SavedPost` schema. Keep `published_at` (post publication time) distinct from `saved_at` (bookmark time). Both come from the source payload; never fabricate `saved_at` from the sync/DB-insert moment — leave it `None` when the source does not expose the original bookmark time (`upsert_post` preserves a known value and stores `NULL` otherwise).
37
+ - Store the raw Substack `audience` string (e.g. `everyone`, `only_paid`) verbatim on `SavedPost.audience` rather than only collapsing it into the `is_paywalled` boolean; `parse_remote_post()` still derives `is_paywalled` from it (`audience == "only_paid"`) for backward-compatible boolean filtering, but the original value is preserved for exact-match filtering and discovery via `list_audiences()`.
38
+ - Canonicalize every post URL through `canonicalize_url()` before storage or lookup. It strips known tracking parameters, fragments, and non-root trailing slashes; SQLite uniqueness and upsert behavior depend on that canonical form.
39
+ - Treat unsaving as a soft delete: use `soft_delete_post()` (single post, e.g. the `unsave` command/tool) or `reconcile_unsaved_posts()` (bulk, called from a force sync) to set `is_saved = 0` and `unsaved_at`, retaining the record for history. Default list/search/publication queries intentionally include only `is_saved = 1`.
40
+ - The local cache always reflects the user's intent for `save`/`unsave`, regardless of whether the remote toggle on Substack could be confirmed — never block the local write on remote confirmation. Instead, surface the confirmation status (`"confirmed"` vs. `"unconfirmed"/"not_found"/"click_failed"`) as a clearly-labeled warning to the caller (CLI: yellow `click.secho`; MCP: a `warning` key and `remote_confirmed: bool` in the tool's dict response) so an unconfirmed remote action isn't silently reported as a full success. A subsequent `sync --force` will self-correct the local cache via `reconcile_unsaved_posts()` if a "confirmed" save/unsave didn't actually happen remotely.
41
+ - Do not write directly to `posts_fts`; use the `posts` repository operations so the SQLite insert/update/delete triggers maintain the FTS5 index.
42
+ - Database-facing tests use `tmp_path` databases. CLI and MCP tests set `SUBSTACK_SAVED_DB_PATH` with `monkeypatch` so they never access a developer's real cache. Sync tests inject a `SubstackSavedPostsClient` substitute rather than opening Playwright or contacting Substack.
43
+ - Browser state and SQLite data are credentials or user data. Keep `storage_state.json`, browser-state directories, `.env` files, and database files untracked; configuration creates data directories with user-only permissions on POSIX systems.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Toni Hermoso Pulido
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,218 @@
1
+ Metadata-Version: 2.4
2
+ Name: substack-saved-mcp
3
+ Version: 0.1.0
4
+ Summary: Local stdio-based MCP server and sync engine for Substack saved posts
5
+ Author-email: Toni Hermoso Pulido <toniher@cau.cat>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: click>=8.1.0
10
+ Requires-Dist: fastmcp>=0.1.0
11
+ Requires-Dist: playwright>=1.40.0
12
+ Requires-Dist: pydantic>=2.0.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
15
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
16
+ Requires-Dist: ruff==0.16.1; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Substack Saved Posts MCP & CLI
20
+
21
+ A local, stdio-based Model Context Protocol (MCP) server and sync engine for your saved/bookmarked Substack posts.
22
+
23
+ ## Features
24
+
25
+ - **Read & Search**: Full-text search (SQLite FTS5) across saved post titles, excerpts, authors, and publications. Filter by publication, audience tier (e.g. `everyone`, `only_paid`), and date ranges (`published_at` vs `saved_at`). Search also covers a post's **full body text**, but only for posts whose content has already been fetched once via `get-content` / the `get_post_content` tool — a normal `sync` stores metadata and excerpts, not full bodies, so posts you haven't opened yet are matched on their title/excerpt/metadata only, not their full text.
26
+ - **Full Content for LLMs**: Fetch a saved post's full content and get it back cleaned and formatted (headings, lists, links) for feeding directly to an LLM, with the result cached locally for next time.
27
+ - **Save & Unsave**: Bookmark new Substack posts or unbookmark existing ones via authenticated browser sessions.
28
+ - **Offline First**: Fast, offline queries directly from local SQLite cache.
29
+ - **Privacy & Security**: Keeps session credentials local, redacting tokens from logs.
30
+ - **FastMCP Protocol**: Stdio MCP interface with rich tool suite and resources.
31
+
32
+ ---
33
+
34
+ ## Installation with `uv`
35
+
36
+ [`uv`](https://github.com/astral-sh/uv) is the recommended fast Python package manager for installing and running `substack-saved-mcp`.
37
+
38
+ ### Option A: Install System-wide as a Tool (`uv tool install`)
39
+
40
+ Install directly from your local repository folder:
41
+
42
+ ```bash
43
+ # Navigate to the repository
44
+ cd /path/to/substack-saved-mcp
45
+
46
+ # Install system-wide into an isolated uv environment
47
+ uv tool install .
48
+
49
+ # Or install directly from a remote Git repository:
50
+ # uv tool install git+https://github.com/your-username/substack-saved-mcp.git
51
+ ```
52
+
53
+ After installation, `substack-saved-mcp` is immediately available in your PATH:
54
+
55
+ ```bash
56
+ # Verify installation
57
+ substack-saved-mcp --help
58
+ ```
59
+
60
+ To update or uninstall:
61
+ ```bash
62
+ # Upgrade installed tool
63
+ uv tool upgrade substack-saved-mcp
64
+
65
+ # Uninstall tool
66
+ uv tool uninstall substack-saved-mcp
67
+ ```
68
+
69
+ ---
70
+
71
+ ### Option B: Local Development / Development Environment (`uv sync`)
72
+
73
+ If you are developing or modifying the codebase:
74
+
75
+ ```bash
76
+ # Clone and enter directory
77
+ cd substack-saved-mcp
78
+
79
+ # Install dependencies and dev tools (pytest)
80
+ uv sync --extra dev
81
+
82
+ # Run CLI commands using uv run
83
+ uv run substack-saved-mcp --help
84
+
85
+ # Run tests
86
+ uv run pytest
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Quick Start
92
+
93
+ ```bash
94
+ # 1. Initialize local database
95
+ substack-saved-mcp init
96
+
97
+ # 2. Authenticate with Substack (opens interactive browser window once)
98
+ substack-saved-mcp login
99
+
100
+ # 3. Sync saved posts into local cache
101
+ substack-saved-mcp sync
102
+
103
+ # 4. Search saved posts via CLI
104
+ substack-saved-mcp search "artificial intelligence"
105
+
106
+ # 4b. Filter by publication or audience tier (see which tiers are cached with `audiences`)
107
+ substack-saved-mcp audiences
108
+ substack-saved-mcp list --audience only_paid
109
+ substack-saved-mcp search "artificial intelligence" --audience everyone
110
+
111
+ # 5. Save or unsave a post
112
+ substack-saved-mcp save "https://example.substack.com/p/post-slug"
113
+ substack-saved-mcp unsave "https://example.substack.com/p/post-slug"
114
+
115
+ # 6. Get a saved post's full content, cleaned up and ready for an LLM
116
+ substack-saved-mcp get-content "https://example.substack.com/p/post-slug"
117
+
118
+ # 7. Launch stdio MCP server
119
+ substack-saved-mcp serve
120
+ ```
121
+
122
+ ---
123
+
124
+ ## Configuring MCP Clients (Claude Desktop, Goose, Cursor, etc.)
125
+
126
+ Add `substack-saved-mcp` to your MCP client's configuration file (e.g. `claude_desktop_config.json`).
127
+
128
+ ### Using System-Wide Installed Tool (`uv tool` or global binary)
129
+
130
+ ```json
131
+ {
132
+ "mcpServers": {
133
+ "substack-saved": {
134
+ "command": "substack-saved-mcp",
135
+ "args": ["serve"]
136
+ }
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### Using `uv` directly from the Repository Path
142
+
143
+ If you prefer running directly from your repository path without installing system-wide:
144
+
145
+ ```json
146
+ {
147
+ "mcpServers": {
148
+ "substack-saved": {
149
+ "command": "uv",
150
+ "args": [
151
+ "--directory",
152
+ "/path/to/substack-saved-mcp",
153
+ "run",
154
+ "substack-saved-mcp",
155
+ "serve"
156
+ ]
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
+ ---
163
+
164
+ ## Frequently Asked Questions (FAQ)
165
+
166
+ ### Where is the database saved?
167
+
168
+ By default, the SQLite database is saved in your OS application data directory:
169
+
170
+ - **Linux / macOS**: `~/.local/share/substack-saved-mcp/saved_posts.sqlite`
171
+ *(or `$XDG_DATA_HOME/substack-saved-mcp/saved_posts.sqlite` if `XDG_DATA_HOME` is set)*
172
+
173
+ You can specify a custom database path or directory using environment variables:
174
+ ```bash
175
+ export SUBSTACK_SAVED_DB_PATH="/path/to/my/custom_database.sqlite"
176
+ # or
177
+ export SUBSTACK_SAVED_DATA_DIR="/path/to/my/data_dir"
178
+ ```
179
+
180
+ ### Will a browser window pop up when running as an MCP server?
181
+
182
+ **No, a visible browser window will not open during normal MCP operations.**
183
+
184
+ - **Read & Search Tools** (`search_saved_posts`, `list_saved_posts`, `get_saved_post`, `list_publications`, `saved_posts_status`):
185
+ Operate 100% offline using the local SQLite database. Zero browser activity.
186
+ - **Sync & Write Tools** (`sync_saved_posts`, `save_post`, `unsave_post`, `get_post_content`):
187
+ Run in **headless background mode** using the pre-authenticated session stored in `storage_state.json`.
188
+ - **Interactive Login**:
189
+ A visible browser window opens **only** when you manually run `substack-saved-mcp login` from your terminal. If your session expires while using an MCP client, the tool will return a clear error message instructing you to re-authenticate via `substack-saved-mcp login` instead of popping open a browser window unexpectedly.
190
+
191
+ ### What if I get a Playwright "Executable doesn't exist" error?
192
+
193
+ If you encounter an error like `BrowserType.launch: Executable doesn't exist` when running commands (especially `login`), it means Playwright hasn't installed its required browsers in the isolated environment.
194
+
195
+ To fix this, you need to run the `playwright install` command *inside* the environment where the tool is installed.
196
+
197
+ For a system-wide tool installation (via `uv tool install`), run:
198
+ ```bash
199
+ ~/.local/share/uv/tools/substack-saved-mcp/bin/playwright install
200
+ ```
201
+
202
+ If you are using a local development environment (via `uv sync`), run:
203
+ ```bash
204
+ uv run playwright install
205
+ ```
206
+
207
+ ### I edited the source code, but the installed `substack-saved-mcp` command still behaves like the old version. Why?
208
+
209
+ `uv tool install` copies the package into its own isolated environment at install time — it does **not** track your working tree. If you edited files under `src/` (or pulled new commits) after installing the tool system-wide, the globally installed copy is stale and keeps running the old code, even though `uv run substack-saved-mcp ...` from the repo would use the latest source.
210
+
211
+ Reinstall from your current working tree to pick up the changes:
212
+ ```bash
213
+ uv tool install . --no-cache --force
214
+ ```
215
+ - `--force` replaces the existing installed version instead of skipping the install because a version is already present.
216
+ - `--no-cache` ensures a fresh build rather than reusing a cached wheel/build artifact from before your edits.
217
+
218
+ Do this any time after modifying the codebase and before relying on the globally installed `substack-saved-mcp` binary (as opposed to `uv run substack-saved-mcp`, which always reflects the working tree).
@@ -0,0 +1,232 @@
1
+ # Saved Substack Posts MCP Plan
2
+
3
+ ## Goal
4
+
5
+ Create a local, stdio-based MCP server that lets an MCP client (such as Claude Desktop or Goose) search, retrieve, save, and unsave a user's Substack posts. A background/CLI sync process retrieves saved posts through the user's authenticated Substack browser session and maintains a local SQLite cache.
6
+
7
+ The server must work offline against cached data for search and read queries, while write operations (`save_post` and `unsave_post`) use the authenticated Playwright browser session to sync actions directly with Substack and update the local database.
8
+
9
+ The server tracks both **when the post was saved** (`saved_at`) and **the original publication date of the post** (`published_at`).
10
+
11
+ ---
12
+
13
+ ## Assumptions and Boundaries
14
+
15
+ - "Favourite" / "Saved" means a post saved or bookmarked in the user's Substack account (`https://substack.com/saved`).
16
+ - Substack does not provide a documented, stable public API for bookmarking. Sync and write actions use Playwright authenticated contexts or reverse-engineered private API endpoints.
17
+ - Read operations (list, search, get) work 100% offline using the local SQLite cache.
18
+ - Write operations (`save_post`, `unsave_post`) require a valid authenticated session to update Substack remotely, followed immediately by updating the local SQLite cache.
19
+ - The project will use Python 3.11+, Playwright, SQLite (with FTS5), and FastMCP over stdio transport.
20
+
21
+ ---
22
+
23
+ ## Key Architecture & Data Flow
24
+
25
+ ```text
26
+ Substack Authenticated Session (Playwright persistent profile / storage_state.json)
27
+ |
28
+ +------------+------------+
29
+ | |
30
+ (Read / Sync Engine) (Write Operations)
31
+ | |
32
+ v v
33
+ sync_saved_posts.py save_post() / unsave_post()
34
+ - Paginated sync - Calls Substack bookmark API / DOM
35
+ - Auth expiration check - Updates remote bookmark state
36
+ - Normalizes posts - Updates local DB immediately
37
+ | |
38
+ +------------+------------+
39
+ |
40
+ v
41
+ saved_posts.sqlite (SQLite + FTS5)
42
+ - Stores published_at & saved_at
43
+ - Tracks is_saved (1 = active, 0 = unsaved)
44
+ |
45
+ v
46
+ FastMCP stdio Server
47
+ - Tools: list, search, get_post, save_post, unsave_post, list_publications, status, sync
48
+ - Resources: substack://posts/{id}, substack://publications
49
+ |
50
+ v
51
+ MCP Client
52
+ ```
53
+
54
+ ---
55
+
56
+ ## Repository Layout
57
+
58
+ ```text
59
+ substack-saved-mcp/
60
+ pyproject.toml
61
+ README.md
62
+ PLAN.md
63
+ src/substack_saved_mcp/
64
+ __init__.py
65
+ config.py # Environment & default OS app-data paths
66
+ models.py # Dataclasses/Pydantic schemas for posts & sync runs
67
+ database.py # SQLite schema, FTS5 virtual table, migrations, queries
68
+ url_utils.py # Canonicalization & tracking query param stripping
69
+ substack_client.py # Playwright reader & writer for Substack posts
70
+ sync.py # Incremental & full sync engine with rate limiting
71
+ mcp_server.py # FastMCP server, read/write tools, and resources
72
+ cli.py # Click/Typer CLI (login, sync, serve, save, unsave, status, inspect)
73
+ tests/
74
+ test_database.py
75
+ test_url_utils.py
76
+ test_normalization.py
77
+ test_search.py
78
+ test_write_actions.py # Tests for save and unsave database & API handlers
79
+ test_mcp_server.py
80
+ fixtures/ # Sanitized sample API response JSON payloads
81
+ data/ # Gitignored default SQLite location
82
+ browser-state/ # Gitignored Playwright storage_state.json & user profile
83
+ ```
84
+
85
+ ---
86
+
87
+ ## Data Model & Schema Design
88
+
89
+ Use SQLite with FTS5 enabled from initial setup to provide fast full-text search across titles, publication names, authors, and excerpts/content.
90
+
91
+ ### Table: `posts`
92
+
93
+ | Column | Type | Constraints / Description |
94
+ |---|---|---|
95
+ | `id` | INTEGER | Primary Key AUTOINCREMENT |
96
+ | `substack_post_id` | TEXT | Remote identifier when available; `UNIQUE` when populated |
97
+ | `url` | TEXT | Canonical post URL (tracking params stripped); `UNIQUE` |
98
+ | `title` | TEXT | Post title |
99
+ | `publication_name` | TEXT | Publication display name |
100
+ | `publication_url` | TEXT | Publication URL |
101
+ | `author_name` | TEXT | Author display name |
102
+ | `published_at` | TEXT | **ISO-8601 UTC timestamp of original post publication** |
103
+ | `saved_at` | TEXT | **ISO-8601 UTC timestamp when post was saved/bookmarked** |
104
+ | `unsaved_at` | TEXT | Optional ISO-8601 UTC timestamp when unsaved |
105
+ | `is_saved` | INTEGER | **1 = active saved post, 0 = unsaved post** (default `1`) |
106
+ | `excerpt` | TEXT | Plain-text summary or lead snippet |
107
+ | `content_text` | TEXT | Optional full body text (plain text or Markdown) |
108
+ | `image_url` | TEXT | Optional lead thumbnail/cover image URL |
109
+ | `is_paywalled` | INTEGER | Boolean flag (0 = free, 1 = paywalled) |
110
+ | `reading_time_minutes` | INTEGER | Optional estimated reading time |
111
+ | `word_count` | INTEGER | Optional word count |
112
+ | `metadata_json` | TEXT | ~~Raw sanitized source JSON retained for future migrations~~ **Removed during implementation** — was never populated; the model and schema no longer include it (pre-existing DBs tolerate the leftover inert column). |
113
+ | `created_at` | TEXT | Local record insertion timestamp (ISO-8601) |
114
+ | `updated_at` | TEXT | Local record update timestamp (ISO-8601) |
115
+
116
+ ### Indexes
117
+ - `idx_posts_url` ON `posts(url)`
118
+ - `idx_posts_published_at` ON `posts(published_at DESC)`
119
+ - `idx_posts_saved_at` ON `posts(saved_at DESC)`
120
+ - `idx_posts_is_saved` ON `posts(is_saved)`
121
+
122
+ ### Virtual Table: `posts_fts` (FTS5)
123
+
124
+ Created over `title`, `publication_name`, `author_name`, `excerpt`, and `content_text` with triggers to keep `posts_fts` synchronized on `INSERT`, `UPDATE`, and `DELETE`. Search queries filter by default on `is_saved = 1`.
125
+
126
+ ---
127
+
128
+ ## MCP Tool Suite Specification
129
+
130
+ | Tool Name | Action | Parameters | Description & Output |
131
+ |---|---|---|---|
132
+ | `search_saved_posts` | **Read** | `query` (str, req), `publication` (opt), `published_after` (opt), `published_before` (opt), `saved_after` (opt), `saved_before` (opt), `limit` (int, default 20) | Full-text FTS5 search across saved posts. Returns list of concise posts with `title`, `url`, `publication_name`, `published_at`, `saved_at`, `excerpt`. |
133
+ | `list_saved_posts` | **Read** | `limit` (int, default 20), `offset` (int, default 0), `publication` (opt), `sort_by` (`saved_at` \| `published_at`, default `saved_at`) | Paginated list of active saved posts ordered by `saved_at` or `published_at`. |
134
+ | `get_saved_post` | **Read** | `url_or_id` (str, req) | Detailed view of a single post, including full metadata, timestamps (`published_at`, `saved_at`), and `content_text` if cached. |
135
+ | `save_post` | **Write** | `url` (str, req) | Saves/bookmarks a Substack post remotely on Substack and updates local DB. Returns updated post object with `saved_at` set to current UTC time. |
136
+ | `unsave_post` | **Write** | `url_or_id` (str, req) | Unsaves/unbookmarks a Substack post remotely on Substack and updates local DB (`is_saved = 0`, `unsaved_at = current UTC time`). |
137
+ | `list_publications` | **Read** | None | Returns list of all publications present in cache with count of saved posts for each. |
138
+ | `saved_posts_status` | **Read** | None | Cache statistics: active saved posts count, total unsaved count, last sync run details. |
139
+ | `sync_saved_posts` | **Sync** | `force` (bool, default False) | Triggers background/incremental sync from Substack account into local DB. |
140
+
141
+ ---
142
+
143
+ ## Detailed Write Action & Date Tracking Design
144
+
145
+ ### 1. Date Tracking Strategy (`published_at` vs `saved_at`)
146
+ - **`published_at`**: Extracted from Substack post payload (`post.post_date` or HTML `<time datetime="...">` metadata). Preserves when the article was original authored/published.
147
+ - **`saved_at`**:
148
+ - When synced from Substack: Extracted from Substack's bookmark object (e.g. `bookmark.created_at`).
149
+ - When saved via `save_post` tool: Recorded immediately as local ISO-8601 UTC timestamp (`datetime.now(timezone.utc)`).
150
+
151
+ ### 2. Implementation of `save_post(url)`
152
+ 1. **Canonicalization**: Strip tracking parameters (`utm_*`, `r`, `s`, etc.) from `url`.
153
+ 2. **Remote Execution**:
154
+ - Using `SubstackSavedPostsClient` with `storage_state.json`:
155
+ - Issue authenticated POST request to Substack bookmark endpoint (e.g. `/api/v1/bookmark` or post page action).
156
+ - If session is expired (401/403), fail early with `auth_required` error message.
157
+ 3. **Database Update**:
158
+ - Parse returned post metadata (title, publication, `published_at`).
159
+ - Set `saved_at = datetime.now(timezone.utc).isoformat()`, `is_saved = 1`, `unsaved_at = NULL`.
160
+ - Upsert record into `posts` table and update `posts_fts`.
161
+ 4. **Return**: Structured output with confirmation and full post summary.
162
+
163
+ ### 3. Implementation of `unsave_post(url_or_id)`
164
+ 1. **Resolution**: Lookup canonical post in SQLite by ID or canonical URL.
165
+ 2. **Remote Execution**:
166
+ - Issue authenticated DELETE request to Substack bookmark endpoint for `substack_post_id`.
167
+ - If session is expired (401/403), fail early with `auth_required` error message.
168
+ 3. **Database Update**:
169
+ - Set `is_saved = 0` and `unsaved_at = datetime.now(timezone.utc).isoformat()`.
170
+ - Keep post record in database (soft delete) so history is preserved and sync does not re-add it unexpectedly.
171
+ 4. **Return**: Structured confirmation containing unsaved post title and canonical URL.
172
+
173
+ ---
174
+
175
+ ## Execution Phases
176
+
177
+ ### Phase 1: Package Setup, Schema & Date/URL Utilities
178
+ 1. Initialize Python package (`pyproject.toml` with `fastmcp`, `playwright`, `pytest`).
179
+ 2. Implement `url_utils.py` for query param stripping and canonical URL normalization.
180
+ 3. Implement `database.py` with SQLite schema including `published_at`, `saved_at`, `unsaved_at`, `is_saved`, FTS5 triggers, and CRUD queries.
181
+ 4. Build `cli.py` skeleton with subcommands: `init`, `login`, `sync`, `serve`, `save`, `unsave`, `status`, `inspect-network`.
182
+
183
+ ### Phase 2: Session Capture & Network Inspection (Reads & Writes)
184
+ 1. Implement `substack-saved-mcp login` using Playwright in headful mode (`headless=False`).
185
+ 2. Store authenticated browser storage state in `browser-state/storage_state.json`.
186
+ 3. Implement `inspect-network` command to record network payloads for:
187
+ - Reading saved posts page (`GET /api/v1/saved_posts`)
188
+ - Bookmarking a post (`POST /api/v1/bookmark`)
189
+ - Unbookmarking a post (`DELETE /api/v1/bookmark`)
190
+ 4. Implement `SubstackSavedPostsClient` with `fetch_saved_posts()`, `save_post()`, and `unsave_post()` methods.
191
+
192
+ ### Phase 3: Incremental Sync Engine & Error Guardrails
193
+ 1. Implement paginated sync with rate-limit backoff (500ms delay between pages).
194
+ 2. Store `published_at` (original post date) and `saved_at` (bookmark date) for each normalized post.
195
+ 3. Support incremental sync cutoff when encountering existing saved posts.
196
+ 4. Handle session expiration cleanly with actionable error messages.
197
+
198
+ ### Phase 4: Local Query Engine with FTS5 & Date Filtering
199
+ 1. Implement repository methods in `database.py`:
200
+ - `search_posts(query, publication, published_after, published_before, saved_after, saved_before, limit)`
201
+ - `list_posts(limit, offset, publication, sort_by)`
202
+ - `get_post(url_or_id)`
203
+ - `list_publications()`
204
+ - `save_post_db(post_data)`
205
+ - `unsave_post_db(url_or_id)`
206
+ 2. Ensure search and list queries filter by default on `is_saved = 1`.
207
+
208
+ ### Phase 5: FastMCP Tool & Resource Suite
209
+ 1. Implement `mcp_server.py` with all 8 tools (`search_saved_posts`, `list_saved_posts`, `get_saved_post`, `save_post`, `unsave_post`, `list_publications`, `saved_posts_status`, `sync_saved_posts`).
210
+ 2. Expose resources `substack://posts/{id}` and `substack://publications`.
211
+ 3. Bind `substack-saved-mcp serve` CLI command.
212
+
213
+ ### Phase 6: Privacy, Hardening & Security Audit
214
+ 1. Verify storage paths use standard OS app-data directories.
215
+ 2. Audit log sanitization to ensure tokens/cookies are never logged.
216
+ 3. Validate write actions prevent unexpected side-effects.
217
+
218
+ ### Phase 7: Comprehensive Testing Suite
219
+ 1. **Unit Tests**: Test URL canonicalization, date parsing, FTS queries, and soft-deletion logic.
220
+ 2. **Write Action Tests**: Test `save_post` and `unsave_post` database state transitions and mock API client calls.
221
+ 3. **MCP Integration Tests**: Verify all 8 tools over stdio with fixture database states.
222
+
223
+ ---
224
+
225
+ ## Definition of Done (v1.0)
226
+
227
+ - User can authenticate via `login`.
228
+ - MCP client can search saved posts with full-text FTS5 query matching and date range filters (`published_at`, `saved_at`).
229
+ - MCP client can issue `save_post` to bookmark a new Substack URL remotely and cache it locally.
230
+ - MCP client can issue `unsave_post` to unbookmark a Substack post remotely and soft-delete it locally.
231
+ - `published_at` (original post date) and `saved_at` (bookmark date) are tracked and returned for every post.
232
+ - Fixture tests pass for all read, search, write, and sync tool workflows.