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.
- substack_saved_mcp-0.1.0/.github/workflows/ci.yml +32 -0
- substack_saved_mcp-0.1.0/.github/workflows/pypi.yml +61 -0
- substack_saved_mcp-0.1.0/.gitignore +19 -0
- substack_saved_mcp-0.1.0/.pre-commit-config.yaml +6 -0
- substack_saved_mcp-0.1.0/CLAUDE.md +43 -0
- substack_saved_mcp-0.1.0/LICENSE +21 -0
- substack_saved_mcp-0.1.0/PKG-INFO +218 -0
- substack_saved_mcp-0.1.0/PLAN.md +232 -0
- substack_saved_mcp-0.1.0/README.md +200 -0
- substack_saved_mcp-0.1.0/pyproject.toml +42 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/__init__.py +3 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/cli.py +413 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/config.py +55 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/content_utils.py +151 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/database.py +550 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/mcp_server.py +307 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/models.py +92 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/substack_client.py +718 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/sync.py +267 -0
- substack_saved_mcp-0.1.0/src/substack_saved_mcp/url_utils.py +55 -0
- substack_saved_mcp-0.1.0/tests/test_cli.py +213 -0
- substack_saved_mcp-0.1.0/tests/test_content_utils.py +69 -0
- substack_saved_mcp-0.1.0/tests/test_database.py +340 -0
- substack_saved_mcp-0.1.0/tests/test_mcp_server.py +225 -0
- substack_saved_mcp-0.1.0/tests/test_normalization.py +134 -0
- substack_saved_mcp-0.1.0/tests/test_substack_client.py +339 -0
- substack_saved_mcp-0.1.0/tests/test_sync.py +514 -0
- substack_saved_mcp-0.1.0/tests/test_url_utils.py +20 -0
- 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,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.
|