substack-saved-mcp 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- substack_saved_mcp/cli.py +413 -0
- substack_saved_mcp/config.py +55 -0
- substack_saved_mcp/content_utils.py +151 -0
- substack_saved_mcp/database.py +550 -0
- substack_saved_mcp/mcp_server.py +307 -0
- substack_saved_mcp/models.py +92 -0
- substack_saved_mcp/substack_client.py +718 -0
- substack_saved_mcp/sync.py +267 -0
- substack_saved_mcp/url_utils.py +55 -0
- substack_saved_mcp-0.1.0.dist-info/METADATA +218 -0
- substack_saved_mcp-0.1.0.dist-info/RECORD +15 -0
- substack_saved_mcp-0.1.0.dist-info/WHEEL +4 -0
- substack_saved_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- substack_saved_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Sync engine for retrieving Substack saved posts into the local SQLite cache."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from substack_saved_mcp.database import (
|
|
9
|
+
finish_sync_run,
|
|
10
|
+
get_post,
|
|
11
|
+
init_db,
|
|
12
|
+
reconcile_unsaved_posts,
|
|
13
|
+
start_sync_run,
|
|
14
|
+
upsert_post,
|
|
15
|
+
)
|
|
16
|
+
from substack_saved_mcp.models import SavedPost, SyncRun
|
|
17
|
+
from substack_saved_mcp.substack_client import (
|
|
18
|
+
AuthRequiredError,
|
|
19
|
+
SubstackSavedPostsClient,
|
|
20
|
+
)
|
|
21
|
+
from substack_saved_mcp.url_utils import canonicalize_url
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
# Rough average adult reading speed used to derive reading time from word count.
|
|
26
|
+
WORDS_PER_MINUTE = 200
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _first_positive_int(source: dict[str, Any], *keys: str) -> int | None:
|
|
30
|
+
"""Return the first of ``keys`` whose value coerces to a positive int, else None."""
|
|
31
|
+
for key in keys:
|
|
32
|
+
val = source.get(key)
|
|
33
|
+
if val is None:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
n = int(val)
|
|
37
|
+
except (TypeError, ValueError):
|
|
38
|
+
continue
|
|
39
|
+
if n > 0:
|
|
40
|
+
return n
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_remote_post(raw_data: dict[str, Any]) -> SavedPost:
|
|
45
|
+
"""Parse raw Substack API JSON payload or DOM dict into a typed SavedPost object."""
|
|
46
|
+
# Check if raw_data is a normalized dictionary from DOM extraction. Use an explicit
|
|
47
|
+
# marker: reader-API post objects also carry a "canonical_url" key and must instead
|
|
48
|
+
# go through the full API mapping below (canonicalization, post_date, id, etc.).
|
|
49
|
+
if raw_data.get("_dom"):
|
|
50
|
+
return SavedPost(
|
|
51
|
+
url=raw_data["canonical_url"],
|
|
52
|
+
title=raw_data.get("title") or "Untitled Substack Post",
|
|
53
|
+
publication_name=raw_data.get("publication_name") or "Substack",
|
|
54
|
+
publication_url=raw_data.get("publication_url"),
|
|
55
|
+
author_name=raw_data.get("author_name"),
|
|
56
|
+
excerpt=raw_data.get("excerpt"),
|
|
57
|
+
published_at=raw_data.get("published_at"),
|
|
58
|
+
saved_at=raw_data.get("saved_at"),
|
|
59
|
+
audience=raw_data.get("audience"),
|
|
60
|
+
is_saved=1,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Substack API json mapping
|
|
64
|
+
post_obj = raw_data.get("post") or raw_data.get("item") or raw_data
|
|
65
|
+
pub_obj = raw_data.get("publication") or post_obj.get("publication") or {}
|
|
66
|
+
|
|
67
|
+
substack_id = str(post_obj.get("id")) if post_obj.get("id") else None
|
|
68
|
+
raw_url = post_obj.get("canonical_url") or post_obj.get("url") or ""
|
|
69
|
+
clean_url = canonicalize_url(raw_url)
|
|
70
|
+
|
|
71
|
+
title = post_obj.get("title") or "Untitled Substack Post"
|
|
72
|
+
pub_name = pub_obj.get("name") or "Substack"
|
|
73
|
+
pub_url = (
|
|
74
|
+
pub_obj.get("custom_domain")
|
|
75
|
+
or f"https://{pub_obj.get('subdomain')}.substack.com"
|
|
76
|
+
if pub_obj.get("subdomain")
|
|
77
|
+
else None
|
|
78
|
+
)
|
|
79
|
+
author = (
|
|
80
|
+
post_obj.get("author")
|
|
81
|
+
or post_obj.get("author_name")
|
|
82
|
+
or (pub_obj.get("author_name") if pub_obj else None)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Dates. Leave saved_at unknown (None) rather than stamping the sync moment.
|
|
86
|
+
published_at = post_obj.get("post_date") or post_obj.get("published_at")
|
|
87
|
+
saved_at = raw_data.get("created_at") or raw_data.get("saved_at")
|
|
88
|
+
|
|
89
|
+
excerpt = (
|
|
90
|
+
post_obj.get("description")
|
|
91
|
+
or post_obj.get("subtitle")
|
|
92
|
+
or post_obj.get("excerpt")
|
|
93
|
+
)
|
|
94
|
+
content_text = post_obj.get("body_html") or post_obj.get("content_text")
|
|
95
|
+
image_url = post_obj.get("cover_image") or post_obj.get("image_url")
|
|
96
|
+
audience = post_obj.get("audience")
|
|
97
|
+
is_paywalled = 1 if audience == "only_paid" or post_obj.get("is_paywalled") else 0
|
|
98
|
+
|
|
99
|
+
# Word count: Substack's exact reader-API field name isn't confirmed, so map the
|
|
100
|
+
# most likely candidates defensively (verify with `inspect-network` if these stay
|
|
101
|
+
# empty). Reading time is *derived* from word count rather than read from a field,
|
|
102
|
+
# since a wrong guess about that field's unit (seconds vs minutes) would store a
|
|
103
|
+
# badly wrong value — deriving at ~WPM is unambiguous and is how Substack itself
|
|
104
|
+
# presents it.
|
|
105
|
+
word_count = _first_positive_int(post_obj, "wordcount", "word_count", "words")
|
|
106
|
+
reading_time_minutes = (
|
|
107
|
+
-(-word_count // WORDS_PER_MINUTE)
|
|
108
|
+
if word_count
|
|
109
|
+
else None # ceil division, min 1
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return SavedPost(
|
|
113
|
+
substack_post_id=substack_id,
|
|
114
|
+
url=clean_url,
|
|
115
|
+
title=title,
|
|
116
|
+
publication_name=pub_name,
|
|
117
|
+
publication_url=pub_url,
|
|
118
|
+
author_name=author,
|
|
119
|
+
published_at=published_at,
|
|
120
|
+
saved_at=saved_at,
|
|
121
|
+
is_saved=1,
|
|
122
|
+
excerpt=excerpt,
|
|
123
|
+
content_text=content_text,
|
|
124
|
+
image_url=image_url,
|
|
125
|
+
audience=audience,
|
|
126
|
+
is_paywalled=is_paywalled,
|
|
127
|
+
word_count=word_count,
|
|
128
|
+
reading_time_minutes=reading_time_minutes,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sync_saved_posts(
|
|
133
|
+
force: bool = False,
|
|
134
|
+
db_path: Path | None = None,
|
|
135
|
+
client: SubstackSavedPostsClient | None = None,
|
|
136
|
+
) -> SyncRun:
|
|
137
|
+
"""Execute an incremental or full sync of Substack saved posts into SQLite cache."""
|
|
138
|
+
init_db(db_path)
|
|
139
|
+
sync_mode = "full" if force else "incremental"
|
|
140
|
+
sync_id = start_sync_run(sync_mode=sync_mode, db_path=db_path)
|
|
141
|
+
|
|
142
|
+
active_client = client or SubstackSavedPostsClient()
|
|
143
|
+
active_client.reset_cache()
|
|
144
|
+
total_fetched = 0
|
|
145
|
+
total_upserted = 0
|
|
146
|
+
consecutive_matches = 0
|
|
147
|
+
MAX_CONSECUTIVE_MATCHES = 3
|
|
148
|
+
# Only populated meaningfully for a force/full sync, which enumerates every
|
|
149
|
+
# currently-saved remote post; an incremental sync may stop early and its
|
|
150
|
+
# partial list must never be used to infer removals.
|
|
151
|
+
remote_urls: list[str] = []
|
|
152
|
+
|
|
153
|
+
page_size = 50
|
|
154
|
+
offset = 0
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
while True:
|
|
158
|
+
remote_items = active_client.fetch_saved_posts_page(
|
|
159
|
+
limit=page_size, offset=offset
|
|
160
|
+
)
|
|
161
|
+
if not remote_items:
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
total_fetched += len(remote_items)
|
|
165
|
+
|
|
166
|
+
for item in remote_items:
|
|
167
|
+
parsed_post = parse_remote_post(item)
|
|
168
|
+
if not parsed_post.url:
|
|
169
|
+
continue
|
|
170
|
+
remote_urls.append(parsed_post.url)
|
|
171
|
+
|
|
172
|
+
# Incremental sync check
|
|
173
|
+
if not force:
|
|
174
|
+
existing = get_post(parsed_post.url, db_path=db_path)
|
|
175
|
+
if (
|
|
176
|
+
existing
|
|
177
|
+
and existing.is_saved == 1
|
|
178
|
+
and existing.saved_at == parsed_post.saved_at
|
|
179
|
+
):
|
|
180
|
+
consecutive_matches += 1
|
|
181
|
+
if consecutive_matches >= MAX_CONSECUTIVE_MATCHES:
|
|
182
|
+
logger.info(
|
|
183
|
+
f"Incremental sync: encountered {consecutive_matches} existing matches. Stopping early."
|
|
184
|
+
)
|
|
185
|
+
break
|
|
186
|
+
else:
|
|
187
|
+
consecutive_matches = 0
|
|
188
|
+
|
|
189
|
+
upsert_post(parsed_post, db_path=db_path)
|
|
190
|
+
total_upserted += 1
|
|
191
|
+
|
|
192
|
+
if not force and consecutive_matches >= MAX_CONSECUTIVE_MATCHES:
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
if len(remote_items) < page_size:
|
|
196
|
+
break
|
|
197
|
+
|
|
198
|
+
offset += page_size
|
|
199
|
+
|
|
200
|
+
reconciled_count = 0
|
|
201
|
+
if force:
|
|
202
|
+
reconciled_count = reconcile_unsaved_posts(remote_urls, db_path=db_path)
|
|
203
|
+
if reconciled_count:
|
|
204
|
+
logger.info(
|
|
205
|
+
f"Reconciliation: soft-deleted {reconciled_count} post(s) no longer in the remote saved list."
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
finish_sync_run(
|
|
209
|
+
sync_id=sync_id,
|
|
210
|
+
status="success",
|
|
211
|
+
fetched_count=total_fetched,
|
|
212
|
+
upserted_count=total_upserted,
|
|
213
|
+
reconciled_count=reconciled_count,
|
|
214
|
+
db_path=db_path,
|
|
215
|
+
)
|
|
216
|
+
return SyncRun(
|
|
217
|
+
id=sync_id,
|
|
218
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
219
|
+
completed_at=datetime.now(UTC).isoformat(),
|
|
220
|
+
status="success",
|
|
221
|
+
sync_mode=sync_mode,
|
|
222
|
+
fetched_count=total_fetched,
|
|
223
|
+
upserted_count=total_upserted,
|
|
224
|
+
reconciled_count=reconciled_count,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
except AuthRequiredError as e:
|
|
228
|
+
msg = str(e)
|
|
229
|
+
finish_sync_run(
|
|
230
|
+
sync_id=sync_id,
|
|
231
|
+
status="auth_required",
|
|
232
|
+
fetched_count=total_fetched,
|
|
233
|
+
upserted_count=total_upserted,
|
|
234
|
+
error_message=msg,
|
|
235
|
+
db_path=db_path,
|
|
236
|
+
)
|
|
237
|
+
return SyncRun(
|
|
238
|
+
id=sync_id,
|
|
239
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
240
|
+
completed_at=datetime.now(UTC).isoformat(),
|
|
241
|
+
status="auth_required",
|
|
242
|
+
sync_mode=sync_mode,
|
|
243
|
+
fetched_count=total_fetched,
|
|
244
|
+
upserted_count=total_upserted,
|
|
245
|
+
error_message=msg,
|
|
246
|
+
)
|
|
247
|
+
except Exception as e:
|
|
248
|
+
msg = f"Sync failed: {e!s}"
|
|
249
|
+
logger.exception(msg)
|
|
250
|
+
finish_sync_run(
|
|
251
|
+
sync_id=sync_id,
|
|
252
|
+
status="failed",
|
|
253
|
+
fetched_count=total_fetched,
|
|
254
|
+
upserted_count=total_upserted,
|
|
255
|
+
error_message=msg,
|
|
256
|
+
db_path=db_path,
|
|
257
|
+
)
|
|
258
|
+
return SyncRun(
|
|
259
|
+
id=sync_id,
|
|
260
|
+
started_at=datetime.now(UTC).isoformat(),
|
|
261
|
+
completed_at=datetime.now(UTC).isoformat(),
|
|
262
|
+
status="failed",
|
|
263
|
+
sync_mode=sync_mode,
|
|
264
|
+
fetched_count=total_fetched,
|
|
265
|
+
upserted_count=total_upserted,
|
|
266
|
+
error_message=msg,
|
|
267
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""URL normalization and tracking parameter stripping utilities."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
4
|
+
|
|
5
|
+
# Common tracking parameters present in Substack links
|
|
6
|
+
TRACKING_PARAMS = {
|
|
7
|
+
"utm_source",
|
|
8
|
+
"utm_medium",
|
|
9
|
+
"utm_campaign",
|
|
10
|
+
"utm_term",
|
|
11
|
+
"utm_content",
|
|
12
|
+
"r",
|
|
13
|
+
"s",
|
|
14
|
+
"ref",
|
|
15
|
+
"post_id",
|
|
16
|
+
"isFreemail",
|
|
17
|
+
"triedSigningIn",
|
|
18
|
+
"action",
|
|
19
|
+
"showWelcome",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def canonicalize_url(url: str) -> str:
|
|
24
|
+
"""Normalize post URL by stripping tracking query params and trailing slash.
|
|
25
|
+
|
|
26
|
+
Example:
|
|
27
|
+
Input: https://example.substack.com/p/my-post-slug?utm_source=substack&r=1a2b3#comments
|
|
28
|
+
Output: https://example.substack.com/p/my-post-slug
|
|
29
|
+
"""
|
|
30
|
+
if not url:
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
parsed = urlparse(url.strip())
|
|
34
|
+
# Keep query parameters that are NOT in TRACKING_PARAMS
|
|
35
|
+
query_params = parse_qsl(parsed.query, keep_blank_values=False)
|
|
36
|
+
filtered_params = [(k, v) for k, v in query_params if k not in TRACKING_PARAMS]
|
|
37
|
+
clean_query = urlencode(filtered_params)
|
|
38
|
+
|
|
39
|
+
# Normalize path (remove trailing slash except for root path)
|
|
40
|
+
path = parsed.path
|
|
41
|
+
if len(path) > 1 and path.endswith("/"):
|
|
42
|
+
path = path[:-1]
|
|
43
|
+
|
|
44
|
+
# Reconstruct clean canonical URL (strip fragment/anchor as well)
|
|
45
|
+
clean_url = urlunparse(
|
|
46
|
+
(
|
|
47
|
+
parsed.scheme.lower(),
|
|
48
|
+
parsed.netloc.lower(),
|
|
49
|
+
path,
|
|
50
|
+
parsed.params,
|
|
51
|
+
clean_query,
|
|
52
|
+
"", # fragment stripped
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
return clean_url
|
|
@@ -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,15 @@
|
|
|
1
|
+
substack_saved_mcp/__init__.py,sha256=Eqt8L6DOMTXLExCNmSqhBIkZJqvg32gDf_dF2y65mYA,74
|
|
2
|
+
substack_saved_mcp/cli.py,sha256=GbabUl9dGO48-2d6lQLc7CCHWgMguAAs-7Uz-QHF6Ms,13755
|
|
3
|
+
substack_saved_mcp/config.py,sha256=oDZveru_tPZigMvEyYkvKsJbNhupj51btgWI_zk4sqI,1757
|
|
4
|
+
substack_saved_mcp/content_utils.py,sha256=pFADyAmXhtAO-fMdxMWO_ynf30Ez-1UKpu-g-O2gEqQ,4231
|
|
5
|
+
substack_saved_mcp/database.py,sha256=gmW0kQ-ehZn3z7BhF3DiLYy0AnN3dLMfF42N1Bak1k4,21562
|
|
6
|
+
substack_saved_mcp/mcp_server.py,sha256=rDs793FT1yIwvjg_CDkeYvKBtL1NWVS-5sEK3R7VGpQ,10413
|
|
7
|
+
substack_saved_mcp/models.py,sha256=g01TWK8N0EBuzuZEhJ2WrSe9I8QkeQk8t_yGjBaw8Ro,2738
|
|
8
|
+
substack_saved_mcp/substack_client.py,sha256=Gsh_XJ9zCCrA3ilZdUE0jbMGLxghFh5tEuQ_jhLmRfU,28873
|
|
9
|
+
substack_saved_mcp/sync.py,sha256=04TVwBS8vP0DWoIgoZ4ERLGnVEsuJLu505nyYmHS3qw,9433
|
|
10
|
+
substack_saved_mcp/url_utils.py,sha256=WIswnksq_VLKvcHl-K7cAaE71L4M0LASpkBRxHunL6Y,1530
|
|
11
|
+
substack_saved_mcp-0.1.0.dist-info/METADATA,sha256=38RMyKCkehej-VMzM6zUfVLNjIumVoNnwtDAThBEWFM,7937
|
|
12
|
+
substack_saved_mcp-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
13
|
+
substack_saved_mcp-0.1.0.dist-info/entry_points.txt,sha256=3gZ2dPi4HRivOBMPaO3TcaW2NUR978nydg7LtcY3O0s,66
|
|
14
|
+
substack_saved_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=ZTJDgcyOdyNUW1neSxNwjSrNz0mZ7EYm3tXr_FbXdTU,1076
|
|
15
|
+
substack_saved_mcp-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|