substack-api-mcp 1.0.2__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_api_mcp/__init__.py +28 -0
- substack_api_mcp/api.py +213 -0
- substack_api_mcp/app.py +38 -0
- substack_api_mcp/cli.py +198 -0
- substack_api_mcp/core/__init__.py +0 -0
- substack_api_mcp/core/account_guard.py +33 -0
- substack_api_mcp/core/context.py +175 -0
- substack_api_mcp/core/errors.py +78 -0
- substack_api_mcp/core/helpers.py +33 -0
- substack_api_mcp/core/tools.py +106 -0
- substack_api_mcp/mcp_context.py +126 -0
- substack_api_mcp/mcp_server.py +67 -0
- substack_api_mcp/runner.py +106 -0
- substack_api_mcp/scripting.py +73 -0
- substack_api_mcp/session.py +197 -0
- substack_api_mcp/substack/__init__.py +0 -0
- substack_api_mcp/substack/auth.py +26 -0
- substack_api_mcp/substack/client.py +172 -0
- substack_api_mcp/substack/feed.py +31 -0
- substack_api_mcp/substack/markdown.py +239 -0
- substack_api_mcp/substack/publishing.py +70 -0
- substack_api_mcp/substack/subscribers.py +94 -0
- substack_api_mcp/substack/tools/__init__.py +0 -0
- substack_api_mcp/substack/tools/add_subscriber.py +60 -0
- substack_api_mcp/substack/tools/cancel_scheduled_post.py +54 -0
- substack_api_mcp/substack/tools/create_draft.py +75 -0
- substack_api_mcp/substack/tools/create_post_tag.py +49 -0
- substack_api_mcp/substack/tools/create_subscriber_tag.py +46 -0
- substack_api_mcp/substack/tools/delete_comment.py +45 -0
- substack_api_mcp/substack/tools/delete_post.py +47 -0
- substack_api_mcp/substack/tools/duplicate_post.py +55 -0
- substack_api_mcp/substack/tools/export_subscribers.py +42 -0
- substack_api_mcp/substack/tools/follow_user.py +46 -0
- substack_api_mcp/substack/tools/get_audience_insights.py +93 -0
- substack_api_mcp/substack/tools/get_conversation.py +71 -0
- substack_api_mcp/substack/tools/get_daily_stats.py +77 -0
- substack_api_mcp/substack/tools/get_feed.py +83 -0
- substack_api_mcp/substack/tools/get_post.py +75 -0
- substack_api_mcp/substack/tools/get_post_engagement.py +68 -0
- substack_api_mcp/substack/tools/get_post_stats.py +73 -0
- substack_api_mcp/substack/tools/get_post_traffic.py +72 -0
- substack_api_mcp/substack/tools/get_publication_stats.py +74 -0
- substack_api_mcp/substack/tools/get_unread_counts.py +40 -0
- substack_api_mcp/substack/tools/like_comment.py +57 -0
- substack_api_mcp/substack/tools/like_post.py +45 -0
- substack_api_mcp/substack/tools/list_activity.py +111 -0
- substack_api_mcp/substack/tools/list_comments.py +79 -0
- substack_api_mcp/substack/tools/list_conversations.py +78 -0
- substack_api_mcp/substack/tools/list_note_replies.py +79 -0
- substack_api_mcp/substack/tools/list_notes.py +80 -0
- substack_api_mcp/substack/tools/list_post_tags.py +48 -0
- substack_api_mcp/substack/tools/list_posts.py +105 -0
- substack_api_mcp/substack/tools/list_recommendations.py +109 -0
- substack_api_mcp/substack/tools/list_sections.py +60 -0
- substack_api_mcp/substack/tools/list_subscriber_tags.py +36 -0
- substack_api_mcp/substack/tools/list_subscribers.py +82 -0
- substack_api_mcp/substack/tools/list_subscriptions.py +76 -0
- substack_api_mcp/substack/tools/list_traffic_sources.py +89 -0
- substack_api_mcp/substack/tools/pin_comment.py +46 -0
- substack_api_mcp/substack/tools/publish_note.py +83 -0
- substack_api_mcp/substack/tools/publish_post.py +72 -0
- substack_api_mcp/substack/tools/recommend_publication.py +66 -0
- substack_api_mcp/substack/tools/remove_comment.py +45 -0
- substack_api_mcp/substack/tools/remove_subscriber.py +57 -0
- substack_api_mcp/substack/tools/reply_to_comment.py +63 -0
- substack_api_mcp/substack/tools/reply_to_note.py +56 -0
- substack_api_mcp/substack/tools/schedule_post.py +78 -0
- substack_api_mcp/substack/tools/search_substack.py +84 -0
- substack_api_mcp/substack/tools/send_message.py +65 -0
- substack_api_mcp/substack/tools/tag_post.py +55 -0
- substack_api_mcp/substack/tools/tag_subscribers.py +75 -0
- substack_api_mcp/substack/tools/unpublish_post.py +50 -0
- substack_api_mcp/substack/tools/update_draft.py +93 -0
- substack_api_mcp-1.0.2.dist-info/METADATA +516 -0
- substack_api_mcp-1.0.2.dist-info/RECORD +78 -0
- substack_api_mcp-1.0.2.dist-info/WHEEL +4 -0
- substack_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
- substack_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Get post."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.tools import tool
|
|
8
|
+
from substack_api_mcp.substack.client import (
|
|
9
|
+
body_doc,
|
|
10
|
+
draft,
|
|
11
|
+
post_id,
|
|
12
|
+
post_status,
|
|
13
|
+
post_url,
|
|
14
|
+
publication,
|
|
15
|
+
scheduled_at,
|
|
16
|
+
)
|
|
17
|
+
from substack_api_mcp.substack.markdown import to_markdown
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from substack_api_mcp.core.context import ToolContext
|
|
21
|
+
|
|
22
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {"post_id": {"type": "string", "description": "Post ID from list_posts."}},
|
|
25
|
+
"required": ["post_id"],
|
|
26
|
+
"additionalProperties": False,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
30
|
+
"post_id": "148213977",
|
|
31
|
+
"title": "The quiet launch",
|
|
32
|
+
"subtitle": "What waiting taught me",
|
|
33
|
+
"body_markdown": "Last month I shipped without telling anyone...",
|
|
34
|
+
"audience": "everyone",
|
|
35
|
+
"status": "published",
|
|
36
|
+
"published_at": "2026-10-06T12:00:00Z",
|
|
37
|
+
"url": "https://yourname.substack.com/p/the-quiet-launch",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@tool(
|
|
42
|
+
name="get_post",
|
|
43
|
+
title="Get post",
|
|
44
|
+
access="read",
|
|
45
|
+
confirm=False,
|
|
46
|
+
description="Full post: title, subtitle, body as Markdown, audience, URL and dates.",
|
|
47
|
+
details=(
|
|
48
|
+
"Returns one post of your publication, draft, scheduled or published, with its full body as Markdown, "
|
|
49
|
+
"including the paywalled part. Images come back as URLs. Buttons, embeds and footnotes come back as "
|
|
50
|
+
"plain text, so writing the body back with update_draft drops them. A scheduled post also has "
|
|
51
|
+
"publish_at."
|
|
52
|
+
),
|
|
53
|
+
input_schema=INPUT_SCHEMA,
|
|
54
|
+
route="GET /posts/{post_id}",
|
|
55
|
+
example_response=EXAMPLE_RESPONSE,
|
|
56
|
+
)
|
|
57
|
+
async def get_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
58
|
+
|
|
59
|
+
pub = await publication(ctx)
|
|
60
|
+
post = await draft(ctx, pub, post_id(args["post_id"]))
|
|
61
|
+
published = bool(post.get("is_published"))
|
|
62
|
+
status = post_status(post)
|
|
63
|
+
answer = {
|
|
64
|
+
"post_id": str(post["id"]),
|
|
65
|
+
"title": (post.get("title") if published else None) or post.get("draft_title") or "",
|
|
66
|
+
"subtitle": (post.get("subtitle") if published else None) or post.get("draft_subtitle") or "",
|
|
67
|
+
"body_markdown": to_markdown(body_doc(post)),
|
|
68
|
+
"audience": post.get("audience"),
|
|
69
|
+
"status": status,
|
|
70
|
+
"published_at": post.get("post_date") if published else None,
|
|
71
|
+
"url": post_url(pub, post),
|
|
72
|
+
}
|
|
73
|
+
if status == "scheduled":
|
|
74
|
+
answer["publish_at"] = scheduled_at(post)
|
|
75
|
+
return answer
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Get post engagement."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.tools import tool
|
|
8
|
+
from substack_api_mcp.substack.client import call, person, post_id, publication
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from substack_api_mcp.core.context import ToolContext
|
|
12
|
+
|
|
13
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {"post_id": {"type": "string", "description": "Post ID from list_posts."}},
|
|
16
|
+
"required": ["post_id"],
|
|
17
|
+
"additionalProperties": False,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
21
|
+
"post_id": "148213977",
|
|
22
|
+
"likes": 142,
|
|
23
|
+
"liked_by": [{"user_id": "41856304", "name": "Maya Chen", "handle": "mayachen"}],
|
|
24
|
+
"comments": 18,
|
|
25
|
+
"commenters": 11,
|
|
26
|
+
"commented_by": [{"user_id": "81309935", "name": "Sam Ortiz", "handle": "samortiz"}],
|
|
27
|
+
"free_signups": 9,
|
|
28
|
+
"paid_subscriptions": 1,
|
|
29
|
+
"unsubscribes": 2,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@tool(
|
|
34
|
+
name="get_post_engagement",
|
|
35
|
+
title="Get post engagement",
|
|
36
|
+
access="read",
|
|
37
|
+
confirm=False,
|
|
38
|
+
description="Who liked and commented on a post, and the subscribers it won and lost.",
|
|
39
|
+
details=(
|
|
40
|
+
"Returns the Engagement and Growth parts of a post's stats: like and comment counts with the people "
|
|
41
|
+
"behind them (name and handle, never email), and the free signups, paid subscriptions and "
|
|
42
|
+
"unsubscribes Substack credits to the post. Counts keep changing for days after a send."
|
|
43
|
+
),
|
|
44
|
+
input_schema=INPUT_SCHEMA,
|
|
45
|
+
route="GET /posts/{post_id}/engagement",
|
|
46
|
+
example_response=EXAMPLE_RESPONSE,
|
|
47
|
+
)
|
|
48
|
+
async def get_post_engagement(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
49
|
+
|
|
50
|
+
pub = await publication(ctx)
|
|
51
|
+
pid = post_id(args["post_id"])
|
|
52
|
+
base = f"{pub['base']}/api/v1/post_management/detail/{pid}"
|
|
53
|
+
engagement = await call(ctx, "GET", f"{base}/engagement")
|
|
54
|
+
growth = await call(ctx, "GET", f"{base}/growth")
|
|
55
|
+
likes = engagement.get("likes") or {}
|
|
56
|
+
commenters = engagement.get("commenters") or {}
|
|
57
|
+
totals = (growth.get("subscribes") or {}).get("totals") or {}
|
|
58
|
+
return {
|
|
59
|
+
"post_id": str(pid),
|
|
60
|
+
"likes": int(likes.get("count") or 0),
|
|
61
|
+
"liked_by": [person(user) for user in likes.get("users") or []],
|
|
62
|
+
"comments": int((engagement.get("commentSummary") or {}).get("total") or 0),
|
|
63
|
+
"commenters": int(commenters.get("count") or 0),
|
|
64
|
+
"commented_by": [person(user) for user in commenters.get("users") or []],
|
|
65
|
+
"free_signups": int((growth.get("signups") or {}).get("total") or 0),
|
|
66
|
+
"paid_subscriptions": int(totals.get("subscribes") or 0),
|
|
67
|
+
"unsubscribes": int((growth.get("unsubscribes") or {}).get("total") or 0),
|
|
68
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Get post stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.errors import ErrorCode, ToolError
|
|
8
|
+
from substack_api_mcp.core.tools import tool
|
|
9
|
+
from substack_api_mcp.substack.client import call, post_id, publication
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from substack_api_mcp.core.context import ToolContext
|
|
13
|
+
|
|
14
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {"post_id": {"type": "string", "description": "Post ID from list_posts."}},
|
|
17
|
+
"required": ["post_id"],
|
|
18
|
+
"additionalProperties": False,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
22
|
+
"post_id": "148213977",
|
|
23
|
+
"recipients": 4120,
|
|
24
|
+
"opens": 2011,
|
|
25
|
+
"open_rate": 0.488,
|
|
26
|
+
"views": 3560,
|
|
27
|
+
"likes": 142,
|
|
28
|
+
"restacks": 18,
|
|
29
|
+
"subscribers_gained": 37,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@tool(
|
|
34
|
+
name="get_post_stats",
|
|
35
|
+
title="Get post stats",
|
|
36
|
+
access="read",
|
|
37
|
+
confirm=False,
|
|
38
|
+
description="Recipients, opens, open rate, views, likes, restacks and subscribers gained.",
|
|
39
|
+
details=(
|
|
40
|
+
"Returns the engagement numbers of one published post, as on its stats page in the dashboard. Email "
|
|
41
|
+
"numbers cover the send; views include the web; likes are reactions. A draft or scheduled post "
|
|
42
|
+
"returns an error. Numbers keep growing for days after a send, so check again later."
|
|
43
|
+
),
|
|
44
|
+
input_schema=INPUT_SCHEMA,
|
|
45
|
+
route="GET /posts/{post_id}/stats",
|
|
46
|
+
example_response=EXAMPLE_RESPONSE,
|
|
47
|
+
)
|
|
48
|
+
async def get_post_stats(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
49
|
+
|
|
50
|
+
pub = await publication(ctx)
|
|
51
|
+
pid = post_id(args["post_id"])
|
|
52
|
+
detail = await call(
|
|
53
|
+
ctx, "GET", f"{pub['base']}/api/v1/post_management/detail/{pid}", params={"offset": 0, "limit": 1}
|
|
54
|
+
)
|
|
55
|
+
rows = detail.get("posts") or []
|
|
56
|
+
if not rows or not rows[0].get("is_published"):
|
|
57
|
+
raise ToolError("This post is not published, so it has no stats yet.", ErrorCode.INVALID_INPUT)
|
|
58
|
+
post = rows[0]
|
|
59
|
+
stats = post.get("stats") or {}
|
|
60
|
+
# Restacks are counted only on the public post.
|
|
61
|
+
public = await call(ctx, "GET", f"{pub['base']}/api/v1/posts/{post['slug']}")
|
|
62
|
+
sent = int(stats.get("sent") or 0)
|
|
63
|
+
opened = int(stats.get("opened") or 0)
|
|
64
|
+
return {
|
|
65
|
+
"post_id": str(pid),
|
|
66
|
+
"recipients": sent,
|
|
67
|
+
"opens": opened,
|
|
68
|
+
"open_rate": round(opened / sent, 3) if sent else 0.0,
|
|
69
|
+
"views": int(stats.get("views") or 0),
|
|
70
|
+
"likes": int(post.get("reaction_count") or 0),
|
|
71
|
+
"restacks": int(public.get("restacks") or 0),
|
|
72
|
+
"subscribers_gained": int(stats.get("signups") or 0),
|
|
73
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Get post traffic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.tools import tool
|
|
8
|
+
from substack_api_mcp.substack.client import call, post_id, publication
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from substack_api_mcp.core.context import ToolContext
|
|
12
|
+
|
|
13
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {"post_id": {"type": "string", "description": "Post ID from list_posts."}},
|
|
16
|
+
"required": ["post_id"],
|
|
17
|
+
"additionalProperties": False,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
21
|
+
"post_id": "148213977",
|
|
22
|
+
"sources": [{"source": "substack app", "views": 812, "share": 0.41, "free_signups": 12, "paid_subscriptions": 2}],
|
|
23
|
+
"categories": [{"category": "Substack", "share": 0.58}],
|
|
24
|
+
"devices": [{"device": "mobile", "share": 0.66}],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@tool(
|
|
29
|
+
name="get_post_traffic",
|
|
30
|
+
title="Get post traffic",
|
|
31
|
+
access="read",
|
|
32
|
+
confirm=False,
|
|
33
|
+
description="Where a post's readers came from: sources, categories and devices.",
|
|
34
|
+
details=(
|
|
35
|
+
"Returns the Traffic part of a post's stats: each source (app, email, a website, search) with its "
|
|
36
|
+
"views, share of all views and the free and paid subscribers it brought, the share per source "
|
|
37
|
+
"category and per device. A post nobody has read yet returns empty lists."
|
|
38
|
+
),
|
|
39
|
+
input_schema=INPUT_SCHEMA,
|
|
40
|
+
route="GET /posts/{post_id}/traffic",
|
|
41
|
+
example_response=EXAMPLE_RESPONSE,
|
|
42
|
+
)
|
|
43
|
+
async def get_post_traffic(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
44
|
+
|
|
45
|
+
pub = await publication(ctx)
|
|
46
|
+
pid = post_id(args["post_id"])
|
|
47
|
+
answer = await call(ctx, "GET", f"{pub['base']}/api/v1/post_management/detail/{pid}/traffic")
|
|
48
|
+
return {
|
|
49
|
+
"post_id": str(pid),
|
|
50
|
+
"sources": [
|
|
51
|
+
{
|
|
52
|
+
"source": row.get("source") or row.get("name") or "",
|
|
53
|
+
"views": int(row.get("views") or 0),
|
|
54
|
+
"share": _share(row),
|
|
55
|
+
"free_signups": int(row.get("signups") or 0),
|
|
56
|
+
"paid_subscriptions": int(row.get("subscribes") or 0),
|
|
57
|
+
}
|
|
58
|
+
for row in answer.get("referrers") or []
|
|
59
|
+
],
|
|
60
|
+
"categories": [
|
|
61
|
+
{"category": row.get("category_type") or row.get("name") or "", "share": _share(row)}
|
|
62
|
+
for row in answer.get("categories") or []
|
|
63
|
+
],
|
|
64
|
+
"devices": [
|
|
65
|
+
{"device": row.get("device_type") or row.get("name") or "", "share": _share(row)}
|
|
66
|
+
for row in answer.get("devices") or []
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _share(row: dict[str, Any]) -> float:
|
|
72
|
+
return round(float(row.get("percent_of_total_views") or 0), 3)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Get publication stats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
from substack_api_mcp.core.tools import tool
|
|
10
|
+
from substack_api_mcp.substack.client import call, publication
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from substack_api_mcp.core.context import ToolContext
|
|
14
|
+
|
|
15
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
16
|
+
"type": "object",
|
|
17
|
+
"properties": {
|
|
18
|
+
"period": {"type": "string", "enum": ["7d", "30d", "90d"], "description": "7d, 30d or 90d. Default: 30d."}
|
|
19
|
+
},
|
|
20
|
+
"required": [],
|
|
21
|
+
"additionalProperties": False,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
25
|
+
"period": "7d",
|
|
26
|
+
"subscribers_total": 4380,
|
|
27
|
+
"subscribers_paid": 412,
|
|
28
|
+
"subscribers_new": 96,
|
|
29
|
+
"subscribers_churned": 11,
|
|
30
|
+
"revenue_usd": 725.48,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@tool(
|
|
35
|
+
name="get_publication_stats",
|
|
36
|
+
title="Get publication stats",
|
|
37
|
+
access="read",
|
|
38
|
+
confirm=False,
|
|
39
|
+
description="Total and paid subscribers, new subscribers, paid cancellations and estimated revenue for a period.",
|
|
40
|
+
details=(
|
|
41
|
+
"Totals are from the publisher dashboard at the end of the period (7, 30 or 90 days). "
|
|
42
|
+
"subscribers_churned counts paid cancellations only; Substack keeps no count of free unsubscribes. "
|
|
43
|
+
"subscribers_new is the growth in total subscribers plus those cancellations. revenue_usd is not "
|
|
44
|
+
"money received: it is the dashboard's annualized revenue at the end of the period, times the "
|
|
45
|
+
"period's share of a year."
|
|
46
|
+
),
|
|
47
|
+
input_schema=INPUT_SCHEMA,
|
|
48
|
+
route="GET /stats",
|
|
49
|
+
example_response=EXAMPLE_RESPONSE,
|
|
50
|
+
)
|
|
51
|
+
async def get_publication_stats(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
52
|
+
|
|
53
|
+
period = args.get("period") or "30d"
|
|
54
|
+
days = int(period[:-1])
|
|
55
|
+
pub = await publication(ctx)
|
|
56
|
+
# The dashboard's summary for the last N days: subscribers and ARR at the start and the end.
|
|
57
|
+
summary = await call(ctx, "GET", f"{pub['base']}/api/v1/publish-dashboard/summary-v2", params={"range": days})
|
|
58
|
+
today = datetime.now(ZoneInfo("Europe/Warsaw")).date()
|
|
59
|
+
window = {"from": (today - timedelta(days=days)).isoformat(), "to": today.isoformat(), "offset": 0, "limit": 1}
|
|
60
|
+
# Cancelled paid subscriptions in the period (Analytics > Unsubscribes); Substack keeps no count of free ones.
|
|
61
|
+
cancels = await call(ctx, "GET", f"{pub['base']}/api/v1/publication/stats/unsubscribes", params=window)
|
|
62
|
+
churned = int(cancels.get("total") or 0)
|
|
63
|
+
total_end = int(summary.get("totalSubscribersEnd") or 0)
|
|
64
|
+
total_start = int(summary.get("totalSubscribersStart") or 0)
|
|
65
|
+
# ARR is in cents, as Substack's other money fields; revenue is the period's share of it.
|
|
66
|
+
arr = float(summary.get("arrEnd") or 0) / 100
|
|
67
|
+
return {
|
|
68
|
+
"period": period,
|
|
69
|
+
"subscribers_total": total_end,
|
|
70
|
+
"subscribers_paid": int(summary.get("paidSubscribersEnd") or 0),
|
|
71
|
+
"subscribers_new": max(total_end - total_start + churned, 0),
|
|
72
|
+
"subscribers_churned": churned,
|
|
73
|
+
"revenue_usd": round(arr * days / 365, 2),
|
|
74
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Get unread counts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.tools import tool
|
|
8
|
+
from substack_api_mcp.substack.client import WEB, call
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from substack_api_mcp.core.context import ToolContext
|
|
12
|
+
|
|
13
|
+
INPUT_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}, "required": [], "additionalProperties": False}
|
|
14
|
+
|
|
15
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {"messages": 2, "message_requests": 1, "activity": 7}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@tool(
|
|
19
|
+
name="get_unread_counts",
|
|
20
|
+
title="Get unread counts",
|
|
21
|
+
access="read",
|
|
22
|
+
confirm=False,
|
|
23
|
+
description="How many unread direct messages, message requests and activity notifications you have.",
|
|
24
|
+
details=(
|
|
25
|
+
"Returns the badges of the Substack app: unread direct messages, pending message requests and unread "
|
|
26
|
+
"activity (likes, replies, new followers, restacks). Reading them marks nothing read."
|
|
27
|
+
),
|
|
28
|
+
input_schema=INPUT_SCHEMA,
|
|
29
|
+
route="GET /unread-counts",
|
|
30
|
+
example_response=EXAMPLE_RESPONSE,
|
|
31
|
+
)
|
|
32
|
+
async def get_unread_counts(ctx: ToolContext, _args: dict[str, Any]) -> Any:
|
|
33
|
+
|
|
34
|
+
messages = await call(ctx, "GET", f"{WEB}/api/v1/messages/unread-count")
|
|
35
|
+
activity = await call(ctx, "GET", f"{WEB}/api/v1/activity/unread")
|
|
36
|
+
return {
|
|
37
|
+
"messages": int(messages.get("unreadCount") or 0),
|
|
38
|
+
"message_requests": int(messages.get("pendingInviteCount") or 0),
|
|
39
|
+
"activity": int(activity.get("count") or 0),
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Like comment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.errors import ErrorCode, ToolError
|
|
8
|
+
from substack_api_mcp.core.tools import tool
|
|
9
|
+
from substack_api_mcp.substack.client import WEB, call, comment_id, publication
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from substack_api_mcp.core.context import ToolContext
|
|
13
|
+
|
|
14
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"comment_id": {"type": "string", "description": "Comment ID from list_comments, or a Note ID."},
|
|
18
|
+
"unlike": {"type": "boolean", "description": "Remove your like instead. Default: false."},
|
|
19
|
+
},
|
|
20
|
+
"required": ["comment_id"],
|
|
21
|
+
"additionalProperties": False,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {"comment_id": "364149469", "liked": True}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@tool(
|
|
28
|
+
name="like_comment",
|
|
29
|
+
title="Like comment or Note",
|
|
30
|
+
access="write",
|
|
31
|
+
confirm=False,
|
|
32
|
+
description="Like or unlike a comment or a Note as you.",
|
|
33
|
+
details=(
|
|
34
|
+
"Adds your heart to any Note or Note reply, or to a comment under a post of your publication, or "
|
|
35
|
+
"removes it with unlike. Comments and Notes share one ID space, so a Note ID from list_notes or "
|
|
36
|
+
"search_substack works too. The author sees your like in their activity."
|
|
37
|
+
),
|
|
38
|
+
input_schema=INPUT_SCHEMA,
|
|
39
|
+
route="PUT /comments/{comment_id}/like",
|
|
40
|
+
example_response=EXAMPLE_RESPONSE,
|
|
41
|
+
)
|
|
42
|
+
async def like_comment(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
43
|
+
|
|
44
|
+
cid = comment_id(args["comment_id"])
|
|
45
|
+
unlike = bool(args.get("unlike"))
|
|
46
|
+
method = "DELETE" if unlike else "POST"
|
|
47
|
+
try:
|
|
48
|
+
# Notes and their replies live on substack.com.
|
|
49
|
+
await call(ctx, method, f"{WEB}/api/v1/comment/{cid}/reaction", body={"reaction": "\u2764"})
|
|
50
|
+
except ToolError as exc:
|
|
51
|
+
if exc.code != ErrorCode.TARGET_ERROR:
|
|
52
|
+
raise
|
|
53
|
+
# A comment under a post answers only on its publication's host, as the post page sends it.
|
|
54
|
+
pub = await publication(ctx)
|
|
55
|
+
body = {"publication_id": pub["id"], "reaction": "\u2764"}
|
|
56
|
+
await call(ctx, method, f"{pub['base']}/api/v1/comment/{cid}/reaction", body=body)
|
|
57
|
+
return {"comment_id": str(cid), "liked": not unlike}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Like post."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.tools import tool
|
|
8
|
+
from substack_api_mcp.substack.client import WEB, call, post_id
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from substack_api_mcp.core.context import ToolContext
|
|
12
|
+
|
|
13
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"post_id": {"type": "string", "description": "Post ID from search_substack, list_posts or a post's data."},
|
|
17
|
+
"unlike": {"type": "boolean", "description": "Remove your like instead. Default: false."},
|
|
18
|
+
},
|
|
19
|
+
"required": ["post_id"],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {"post_id": "197396217", "liked": True}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@tool(
|
|
27
|
+
name="like_post",
|
|
28
|
+
title="Like post",
|
|
29
|
+
access="write",
|
|
30
|
+
confirm=False,
|
|
31
|
+
description="Like or unlike any Substack post as you.",
|
|
32
|
+
details=(
|
|
33
|
+
"Adds your heart to a post on any publication, as the heart under a post does, or removes it with "
|
|
34
|
+
"unlike. The author sees your like in their activity. Liking twice keeps one like."
|
|
35
|
+
),
|
|
36
|
+
input_schema=INPUT_SCHEMA,
|
|
37
|
+
route="PUT /posts/{post_id}/like",
|
|
38
|
+
example_response=EXAMPLE_RESPONSE,
|
|
39
|
+
)
|
|
40
|
+
async def like_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
41
|
+
|
|
42
|
+
pid = post_id(args["post_id"])
|
|
43
|
+
unlike = bool(args.get("unlike"))
|
|
44
|
+
await call(ctx, "DELETE" if unlike else "POST", f"{WEB}/api/v1/post/{pid}/reaction", body={"reaction": "\u2764"})
|
|
45
|
+
return {"post_id": str(pid), "liked": not unlike}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""List activity."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from substack_api_mcp.core.tools import tool
|
|
9
|
+
from substack_api_mcp.substack.client import WEB, call, person
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from substack_api_mcp.core.context import ToolContext
|
|
13
|
+
|
|
14
|
+
_FILTERS = {"all": "all", "replies": "replies-and-mentions", "restacks": "restacks"}
|
|
15
|
+
|
|
16
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"filter": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"enum": ["all", "replies", "restacks"],
|
|
22
|
+
"description": "all, replies (replies and mentions) or restacks. Default: all.",
|
|
23
|
+
},
|
|
24
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous page."},
|
|
25
|
+
},
|
|
26
|
+
"required": [],
|
|
27
|
+
"additionalProperties": False,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
31
|
+
"activity": [
|
|
32
|
+
{
|
|
33
|
+
"type": "note_reply",
|
|
34
|
+
"at": "2026-10-18T16:35:45.367Z",
|
|
35
|
+
"from": [{"user_id": "561099060", "name": "Maya Chen", "handle": "mayachen"}],
|
|
36
|
+
"people": 1,
|
|
37
|
+
"comment_id": "364181517",
|
|
38
|
+
"text": "Rain walks are underrated. Boots or sneakers?",
|
|
39
|
+
"on_comment_id": "364181148",
|
|
40
|
+
"on_post_id": None,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"type": "follow",
|
|
44
|
+
"at": "2026-10-18T16:34:31.102Z",
|
|
45
|
+
"from": [{"user_id": "561099060", "name": "Maya Chen", "handle": "mayachen"}],
|
|
46
|
+
"people": 1,
|
|
47
|
+
"comment_id": None,
|
|
48
|
+
"text": None,
|
|
49
|
+
"on_comment_id": None,
|
|
50
|
+
"on_post_id": None,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
"next_cursor": None,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@tool(
|
|
58
|
+
name="list_activity",
|
|
59
|
+
title="List activity",
|
|
60
|
+
access="read",
|
|
61
|
+
confirm=False,
|
|
62
|
+
description="Your Substack notifications: replies, likes, new followers, restacks, newest first.",
|
|
63
|
+
details=(
|
|
64
|
+
"Returns the Activity tab of the Substack app, newest first. type is Substack's own name, e.g. "
|
|
65
|
+
"note_reply, note_like, comment_reply, comment_like, post_like, follow, restack or mention. from lists "
|
|
66
|
+
"the latest people behind a grouped item and people counts all of them. A reply or mention carries "
|
|
67
|
+
"its comment_id and text; on_comment_id and on_post_id point at what it was about. Reading the list "
|
|
68
|
+
"marks nothing read. Page with next_cursor."
|
|
69
|
+
),
|
|
70
|
+
input_schema=INPUT_SCHEMA,
|
|
71
|
+
route="GET /activity",
|
|
72
|
+
example_response=EXAMPLE_RESPONSE,
|
|
73
|
+
)
|
|
74
|
+
async def list_activity(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
75
|
+
|
|
76
|
+
params: dict[str, Any] = {"filter": _FILTERS[args.get("filter") or "all"]}
|
|
77
|
+
if args.get("cursor"):
|
|
78
|
+
params["after"] = args["cursor"]
|
|
79
|
+
answer = await call(ctx, "GET", f"{WEB}/api/v1/activity-feed-web", params=params)
|
|
80
|
+
users = {user.get("id"): user for user in answer.get("users") or []}
|
|
81
|
+
texts = {
|
|
82
|
+
c.get("id"): c.get("body") for c in (answer.get("comments") or []) + (answer.get("feedItemComments") or [])
|
|
83
|
+
}
|
|
84
|
+
items = answer.get("activityItems") or []
|
|
85
|
+
activity = [
|
|
86
|
+
{
|
|
87
|
+
"type": item.get("type") or "",
|
|
88
|
+
"at": item.get("updated_at") or item.get("created_at"),
|
|
89
|
+
"from": [person(users[uid]) for uid in item.get("recent_sender_ids") or [] if uid in users],
|
|
90
|
+
"people": int(item.get("sender_count") or 0),
|
|
91
|
+
"comment_id": _id(item.get("comment_id")),
|
|
92
|
+
"text": texts.get(item.get("comment_id")) if item.get("comment_id") else None,
|
|
93
|
+
"on_comment_id": _id(item.get("target_comment_id")),
|
|
94
|
+
"on_post_id": _id(item.get("target_post_id")),
|
|
95
|
+
}
|
|
96
|
+
for item in items
|
|
97
|
+
]
|
|
98
|
+
return {"activity": activity, "next_cursor": _cursor(items) if answer.get("more") else None}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _id(value: Any) -> str | None:
|
|
102
|
+
return str(value) if value else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cursor(items: list[dict[str, Any]]) -> str | None:
|
|
106
|
+
"""The app pages back from the oldest item's time, one millisecond earlier."""
|
|
107
|
+
stamps = [item["updated_at"] for item in items if item.get("updated_at")]
|
|
108
|
+
if not stamps:
|
|
109
|
+
return None
|
|
110
|
+
oldest = min(datetime.fromisoformat(stamp.replace("Z", "+00:00")) for stamp in stamps)
|
|
111
|
+
return (oldest - timedelta(milliseconds=1)).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|