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,307 @@
|
|
|
1
|
+
"""FastMCP server exposing tools and resources for searching, retrieving, saving, and unsaving Substack posts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from substack_saved_mcp.content_utils import format_post_for_llm, html_to_llm_text
|
|
9
|
+
from substack_saved_mcp.database import (
|
|
10
|
+
get_post,
|
|
11
|
+
get_status,
|
|
12
|
+
init_db,
|
|
13
|
+
list_posts,
|
|
14
|
+
search_posts,
|
|
15
|
+
soft_delete_post,
|
|
16
|
+
upsert_post,
|
|
17
|
+
)
|
|
18
|
+
from substack_saved_mcp.database import (
|
|
19
|
+
list_audiences as db_list_audiences,
|
|
20
|
+
)
|
|
21
|
+
from substack_saved_mcp.database import (
|
|
22
|
+
list_publications as db_list_publications,
|
|
23
|
+
)
|
|
24
|
+
from substack_saved_mcp.models import (
|
|
25
|
+
AudienceSummary,
|
|
26
|
+
PostSummary,
|
|
27
|
+
PublicationSummary,
|
|
28
|
+
SavedPost,
|
|
29
|
+
SavedPostsStatus,
|
|
30
|
+
SyncRun,
|
|
31
|
+
)
|
|
32
|
+
from substack_saved_mcp.substack_client import (
|
|
33
|
+
AuthRequiredError,
|
|
34
|
+
SubstackSavedPostsClient,
|
|
35
|
+
)
|
|
36
|
+
from substack_saved_mcp.sync import sync_saved_posts as run_sync
|
|
37
|
+
|
|
38
|
+
# Initialize FastMCP Server
|
|
39
|
+
mcp = FastMCP("Substack Saved Posts")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
def search_saved_posts(
|
|
44
|
+
query: str,
|
|
45
|
+
publication: str | None = None,
|
|
46
|
+
audience: str | None = None,
|
|
47
|
+
published_after: str | None = None,
|
|
48
|
+
published_before: str | None = None,
|
|
49
|
+
saved_after: str | None = None,
|
|
50
|
+
saved_before: str | None = None,
|
|
51
|
+
limit: int = 20,
|
|
52
|
+
) -> list[PostSummary]:
|
|
53
|
+
"""Perform full-text FTS5 search across cached saved posts.
|
|
54
|
+
|
|
55
|
+
Searches title, excerpt, publication name, author, and content text.
|
|
56
|
+
Allows filtering by publication name, audience tier (see list_audiences for
|
|
57
|
+
cached values, e.g. "everyone", "only_paid"), original post date
|
|
58
|
+
(published_at), and saved date (saved_at).
|
|
59
|
+
"""
|
|
60
|
+
init_db()
|
|
61
|
+
return search_posts(
|
|
62
|
+
query=query,
|
|
63
|
+
publication=publication,
|
|
64
|
+
audience=audience,
|
|
65
|
+
published_after=published_after,
|
|
66
|
+
published_before=published_before,
|
|
67
|
+
saved_after=saved_after,
|
|
68
|
+
saved_before=saved_before,
|
|
69
|
+
limit=limit,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@mcp.tool()
|
|
74
|
+
def list_saved_posts(
|
|
75
|
+
limit: int = 20,
|
|
76
|
+
offset: int = 0,
|
|
77
|
+
publication: str | None = None,
|
|
78
|
+
audience: str | None = None,
|
|
79
|
+
sort_by: str = "saved_at",
|
|
80
|
+
) -> list[PostSummary]:
|
|
81
|
+
"""List cached saved posts with pagination and optional publication/audience filters.
|
|
82
|
+
|
|
83
|
+
sort_by can be 'saved_at' (when post was bookmarked) or 'published_at' (when post was published).
|
|
84
|
+
audience filters by tier (see list_audiences for cached values, e.g. "everyone", "only_paid").
|
|
85
|
+
"""
|
|
86
|
+
init_db()
|
|
87
|
+
return list_posts(
|
|
88
|
+
limit=limit,
|
|
89
|
+
offset=offset,
|
|
90
|
+
publication=publication,
|
|
91
|
+
audience=audience,
|
|
92
|
+
sort_by=sort_by,
|
|
93
|
+
is_saved_only=True,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mcp.tool()
|
|
98
|
+
def get_saved_post(url_or_id: str) -> SavedPost | None:
|
|
99
|
+
"""Retrieve full cached post details, timestamps (published_at and saved_at), and content by URL or local ID."""
|
|
100
|
+
init_db()
|
|
101
|
+
return get_post(url_or_id)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@mcp.tool()
|
|
105
|
+
def save_post(url: str) -> dict[str, Any]:
|
|
106
|
+
"""Bookmark a Substack post remotely on Substack and save it to the local cache.
|
|
107
|
+
|
|
108
|
+
Requires an active authenticated Substack session (run 'substack-saved-mcp login' if expired).
|
|
109
|
+
Remote confirmation is best-effort: Substack's bookmark button markup isn't
|
|
110
|
+
officially documented, so this detects whether the button's rendered state
|
|
111
|
+
provably changed after clicking. remote_confirmed=False means the post is
|
|
112
|
+
still cached locally, but the tool could not verify the bookmark was
|
|
113
|
+
actually created on Substack's side — a subsequent 'sync --force' will
|
|
114
|
+
correct the local cache if the remote save didn't actually happen.
|
|
115
|
+
"""
|
|
116
|
+
init_db()
|
|
117
|
+
client = SubstackSavedPostsClient()
|
|
118
|
+
saved_model, confirmation = client.save_post(url)
|
|
119
|
+
updated_db_post = upsert_post(saved_model)
|
|
120
|
+
result: dict[str, Any] = {
|
|
121
|
+
"success": True,
|
|
122
|
+
"post": updated_db_post,
|
|
123
|
+
"remote_confirmed": confirmation == "confirmed",
|
|
124
|
+
}
|
|
125
|
+
if confirmation != "confirmed":
|
|
126
|
+
result["warning"] = (
|
|
127
|
+
f"Could not confirm the bookmark toggle on Substack's page (status: {confirmation})."
|
|
128
|
+
)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@mcp.tool()
|
|
133
|
+
def unsave_post(url_or_id: str) -> dict[str, Any]:
|
|
134
|
+
"""Unbookmark a Substack post remotely and soft-delete it in local cache.
|
|
135
|
+
|
|
136
|
+
Soft-deletion preserves post history while removing it from active search/list outputs.
|
|
137
|
+
When the post's Substack ID is known (normally true after a sync), this calls
|
|
138
|
+
Substack's real unsave endpoint directly and is reliably confirmed; otherwise
|
|
139
|
+
it falls back to a best-effort DOM click (see save_post). remote_confirmed=False
|
|
140
|
+
means the post was still soft-deleted locally, but the tool could not
|
|
141
|
+
verify the unbookmark on Substack's side.
|
|
142
|
+
"""
|
|
143
|
+
init_db()
|
|
144
|
+
post = get_post(url_or_id)
|
|
145
|
+
if not post:
|
|
146
|
+
return {
|
|
147
|
+
"success": False,
|
|
148
|
+
"message": f"Post '{url_or_id}' not found in local cache.",
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
client = SubstackSavedPostsClient()
|
|
152
|
+
confirmation = "click_failed"
|
|
153
|
+
try:
|
|
154
|
+
post_id = int(post.substack_post_id) if post.substack_post_id else None
|
|
155
|
+
confirmation = client.unsave_post(post.url, post_id=post_id)
|
|
156
|
+
except AuthRequiredError as e:
|
|
157
|
+
return {"success": False, "message": str(e)}
|
|
158
|
+
except Exception:
|
|
159
|
+
confirmation = (
|
|
160
|
+
"click_failed" # Continue soft deletion locally even if remote unsave fails
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
updated_post = soft_delete_post(post.url)
|
|
164
|
+
message = f"Successfully unsaved post '{post.title}' locally."
|
|
165
|
+
if confirmation != "confirmed":
|
|
166
|
+
message += f" Warning: could not confirm the removal on Substack's page (status: {confirmation})."
|
|
167
|
+
return {
|
|
168
|
+
"success": True,
|
|
169
|
+
"message": message,
|
|
170
|
+
"post": updated_post,
|
|
171
|
+
"remote_confirmed": confirmation == "confirmed",
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@mcp.tool()
|
|
176
|
+
def get_post_content(url_or_id: str, force_refetch: bool = False) -> dict[str, Any]:
|
|
177
|
+
"""Fetch a saved post's full content, cleaned and formatted for LLM consumption.
|
|
178
|
+
|
|
179
|
+
Returns the cached content_text if a previous fetch already stored it, unless
|
|
180
|
+
force_refetch is set. Otherwise fetches the post's page directly, extracts its
|
|
181
|
+
body_html from Substack's server-rendered window._preloads blob, converts it
|
|
182
|
+
to plain text (headings, list items, and links kept readable), and caches the
|
|
183
|
+
result. Requires an active authenticated Substack session. If the content
|
|
184
|
+
can't be located on the page (e.g. Substack changed how it embeds it, or the
|
|
185
|
+
post is paywalled beyond this account's access), returns success=False with a
|
|
186
|
+
message suggesting the caller run 'substack-saved-mcp inspect-network' while
|
|
187
|
+
opening the post so the real content source can be captured.
|
|
188
|
+
"""
|
|
189
|
+
init_db()
|
|
190
|
+
post = get_post(url_or_id)
|
|
191
|
+
if not post:
|
|
192
|
+
return {
|
|
193
|
+
"success": False,
|
|
194
|
+
"message": f"Post '{url_or_id}' not found in local cache.",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if post.content_text and not force_refetch:
|
|
198
|
+
return {
|
|
199
|
+
"success": True,
|
|
200
|
+
"post": post,
|
|
201
|
+
"content": format_post_for_llm(
|
|
202
|
+
title=post.title,
|
|
203
|
+
publication_name=post.publication_name,
|
|
204
|
+
url=post.url,
|
|
205
|
+
body_text=post.content_text,
|
|
206
|
+
author_name=post.author_name,
|
|
207
|
+
published_at=post.published_at,
|
|
208
|
+
),
|
|
209
|
+
"cached": True,
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
client = SubstackSavedPostsClient()
|
|
213
|
+
try:
|
|
214
|
+
result = client.fetch_post_content(post.url)
|
|
215
|
+
except AuthRequiredError as e:
|
|
216
|
+
return {"success": False, "message": str(e)}
|
|
217
|
+
|
|
218
|
+
body_html = result.get("body_html")
|
|
219
|
+
if not body_html:
|
|
220
|
+
return {
|
|
221
|
+
"success": False,
|
|
222
|
+
"post": post,
|
|
223
|
+
"message": (
|
|
224
|
+
"Could not find this post's full content on its page. Substack may "
|
|
225
|
+
"have changed how it embeds it, or this post is paywalled beyond "
|
|
226
|
+
f"this account's access. Run 'substack-saved-mcp inspect-network' "
|
|
227
|
+
f"while opening {post.url} in the browser so the real content "
|
|
228
|
+
"source can be captured, then this tool can be updated."
|
|
229
|
+
),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
body_text = html_to_llm_text(body_html)
|
|
233
|
+
post.content_text = body_text
|
|
234
|
+
updated_post = upsert_post(post)
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"success": True,
|
|
238
|
+
"post": updated_post,
|
|
239
|
+
"content": format_post_for_llm(
|
|
240
|
+
title=updated_post.title,
|
|
241
|
+
publication_name=updated_post.publication_name,
|
|
242
|
+
url=updated_post.url,
|
|
243
|
+
body_text=body_text,
|
|
244
|
+
author_name=updated_post.author_name,
|
|
245
|
+
published_at=updated_post.published_at,
|
|
246
|
+
),
|
|
247
|
+
"cached": False,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@mcp.tool()
|
|
252
|
+
def list_publications() -> list[PublicationSummary]:
|
|
253
|
+
"""List all publications in local cache with post counts."""
|
|
254
|
+
init_db()
|
|
255
|
+
return db_list_publications()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@mcp.tool()
|
|
259
|
+
def list_audiences() -> list[AudienceSummary]:
|
|
260
|
+
"""List distinct audience tiers present in local cache with post counts.
|
|
261
|
+
|
|
262
|
+
Discovers actual values in use (e.g. "everyone", "only_paid") rather than a
|
|
263
|
+
hardcoded enum, since Substack's audience values aren't officially documented
|
|
264
|
+
and may vary or grow over time.
|
|
265
|
+
"""
|
|
266
|
+
init_db()
|
|
267
|
+
return db_list_audiences()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@mcp.tool()
|
|
271
|
+
def saved_posts_status() -> SavedPostsStatus:
|
|
272
|
+
"""Return cache statistics, database path, and last sync run status."""
|
|
273
|
+
init_db()
|
|
274
|
+
return get_status()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@mcp.tool()
|
|
278
|
+
def sync_saved_posts(force: bool = False) -> SyncRun:
|
|
279
|
+
"""Trigger incremental or full resync of saved posts from Substack account into local SQLite cache.
|
|
280
|
+
|
|
281
|
+
Requires an active authenticated Substack session.
|
|
282
|
+
"""
|
|
283
|
+
return run_sync(force=force)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# FastMCP Resources
|
|
287
|
+
@mcp.resource("substack://posts/{post_id}")
|
|
288
|
+
def get_post_resource(post_id: str) -> str:
|
|
289
|
+
"""Resource returning JSON representation of a specific saved post by ID or URL."""
|
|
290
|
+
init_db()
|
|
291
|
+
post = get_post(post_id)
|
|
292
|
+
if not post:
|
|
293
|
+
return json.dumps({"error": f"Post '{post_id}' not found."})
|
|
294
|
+
return post.model_dump_json()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@mcp.resource("substack://publications")
|
|
298
|
+
def get_publications_resource() -> str:
|
|
299
|
+
"""Resource returning JSON list of cached Substack publications."""
|
|
300
|
+
init_db()
|
|
301
|
+
pubs = db_list_publications()
|
|
302
|
+
return json.dumps([p.model_dump() for p in pubs])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def run_server() -> None:
|
|
306
|
+
"""Run FastMCP server over stdio transport."""
|
|
307
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Data models and Pydantic schemas for saved posts and sync metrics."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SavedPost(BaseModel):
|
|
7
|
+
"""Represents a Substack post record stored in SQLite."""
|
|
8
|
+
|
|
9
|
+
id: int | None = None
|
|
10
|
+
substack_post_id: str | None = None
|
|
11
|
+
url: str
|
|
12
|
+
title: str
|
|
13
|
+
publication_name: str
|
|
14
|
+
publication_url: str | None = None
|
|
15
|
+
author_name: str | None = None
|
|
16
|
+
published_at: str | None = None # ISO-8601 UTC timestamp of original post
|
|
17
|
+
saved_at: str | None = None # ISO-8601 UTC timestamp when bookmarked
|
|
18
|
+
unsaved_at: str | None = None # ISO-8601 UTC timestamp when unsaved
|
|
19
|
+
is_saved: int = 1 # 1 = active, 0 = unsaved
|
|
20
|
+
excerpt: str | None = None
|
|
21
|
+
content_text: str | None = None
|
|
22
|
+
image_url: str | None = None
|
|
23
|
+
audience: str | None = (
|
|
24
|
+
None # raw Substack audience tier, e.g. "everyone", "only_paid"
|
|
25
|
+
)
|
|
26
|
+
is_paywalled: int = 0
|
|
27
|
+
reading_time_minutes: int | None = None
|
|
28
|
+
word_count: int | None = None
|
|
29
|
+
created_at: str | None = None
|
|
30
|
+
updated_at: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PostSummary(BaseModel):
|
|
34
|
+
"""Concise representation of a post for listing and search tool responses."""
|
|
35
|
+
|
|
36
|
+
id: int | None = None
|
|
37
|
+
substack_post_id: str | None = None
|
|
38
|
+
url: str
|
|
39
|
+
title: str
|
|
40
|
+
publication_name: str
|
|
41
|
+
author_name: str | None = None
|
|
42
|
+
published_at: str | None = None
|
|
43
|
+
saved_at: str | None = None
|
|
44
|
+
is_saved: int = 1
|
|
45
|
+
excerpt: str | None = None
|
|
46
|
+
image_url: str | None = None
|
|
47
|
+
audience: str | None = None
|
|
48
|
+
is_paywalled: int = 0
|
|
49
|
+
reading_time_minutes: int | None = None
|
|
50
|
+
word_count: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PublicationSummary(BaseModel):
|
|
54
|
+
"""Summary of a publication present in the local cache."""
|
|
55
|
+
|
|
56
|
+
publication_name: str
|
|
57
|
+
publication_url: str | None = None
|
|
58
|
+
post_count: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AudienceSummary(BaseModel):
|
|
62
|
+
"""Summary of an audience tier present in the local cache."""
|
|
63
|
+
|
|
64
|
+
audience: str | None = None
|
|
65
|
+
post_count: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SyncRun(BaseModel):
|
|
69
|
+
"""Tracks execution history of sync operations."""
|
|
70
|
+
|
|
71
|
+
id: int | None = None
|
|
72
|
+
started_at: str
|
|
73
|
+
completed_at: str | None = None
|
|
74
|
+
status: str # 'success', 'partial', 'failed', 'auth_required'
|
|
75
|
+
sync_mode: str = "incremental" # 'incremental' or 'full'
|
|
76
|
+
fetched_count: int = 0
|
|
77
|
+
upserted_count: int = 0
|
|
78
|
+
reconciled_count: int = (
|
|
79
|
+
0 # posts soft-deleted because they left the remote saved list
|
|
80
|
+
)
|
|
81
|
+
error_message: str | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SavedPostsStatus(BaseModel):
|
|
85
|
+
"""Overall status and metrics of the local SQLite cache."""
|
|
86
|
+
|
|
87
|
+
total_saved_posts: int
|
|
88
|
+
total_unsaved_posts: int
|
|
89
|
+
total_publications: int
|
|
90
|
+
last_successful_sync: str | None = None
|
|
91
|
+
last_sync_status: str | None = None
|
|
92
|
+
database_path: str
|