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,79 @@
|
|
|
1
|
+
"""List comments."""
|
|
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": {
|
|
16
|
+
"post_id": {"type": "string", "description": "Post ID from list_posts."},
|
|
17
|
+
"unanswered_only": {"type": "boolean", "description": "Skip threads you already replied to."},
|
|
18
|
+
},
|
|
19
|
+
"required": ["post_id"],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
24
|
+
"comments": [
|
|
25
|
+
{
|
|
26
|
+
"comment_id": "363478059",
|
|
27
|
+
"author": "Maya Chen",
|
|
28
|
+
"body": "Does the next part cover pricing?",
|
|
29
|
+
"created_at": "2026-10-07T08:20:00Z",
|
|
30
|
+
"replied": False,
|
|
31
|
+
"replies": [],
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@tool(
|
|
38
|
+
name="list_comments",
|
|
39
|
+
title="List comments",
|
|
40
|
+
access="read",
|
|
41
|
+
confirm=False,
|
|
42
|
+
description="Comments on a post, with author, text and whether you replied.",
|
|
43
|
+
details=(
|
|
44
|
+
"Returns all comments of one post at once, oldest first, with replies nested under their parent. "
|
|
45
|
+
"Deleted comments are left out. replied is true when you answered anywhere in the thread under the "
|
|
46
|
+
"comment; unanswered_only keeps only top-level comments where you did not. Pass comment_id to "
|
|
47
|
+
"reply_to_comment."
|
|
48
|
+
),
|
|
49
|
+
input_schema=INPUT_SCHEMA,
|
|
50
|
+
route="GET /posts/{post_id}/comments",
|
|
51
|
+
example_response=EXAMPLE_RESPONSE,
|
|
52
|
+
)
|
|
53
|
+
async def list_comments(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
54
|
+
|
|
55
|
+
pub = await publication(ctx)
|
|
56
|
+
pid = post_id(args["post_id"])
|
|
57
|
+
params = {"all_comments": "true", "sort": "oldest_first"}
|
|
58
|
+
answer = await call(ctx, "GET", f"{pub['base']}/api/v1/post/{pid}/comments", params=params)
|
|
59
|
+
comments = [_comment(row, pub["user_id"]) for row in answer.get("comments") or [] if not row.get("deleted")]
|
|
60
|
+
if args.get("unanswered_only"):
|
|
61
|
+
comments = [item for item in comments if not item["replied"]]
|
|
62
|
+
return {"comments": comments}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _comment(row: dict[str, Any], me: Any) -> dict[str, Any]:
|
|
66
|
+
children = [child for child in row.get("children") or [] if not child.get("deleted")]
|
|
67
|
+
return {
|
|
68
|
+
"comment_id": str(row["id"]),
|
|
69
|
+
"author": row.get("name") or "",
|
|
70
|
+
"body": row.get("body") or "",
|
|
71
|
+
"created_at": row.get("date"),
|
|
72
|
+
"replied": _replied(children, me),
|
|
73
|
+
"replies": [_comment(child, me) for child in children],
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _replied(children: list[dict[str, Any]], me: Any) -> bool:
|
|
78
|
+
"""Whether you answered anywhere in the thread under the comment."""
|
|
79
|
+
return any(child.get("user_id") == me or _replied(child.get("children") or [], me) for child in children)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""List conversations."""
|
|
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] = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"unread_only": {"type": "boolean", "description": "Only conversations with unread messages."},
|
|
17
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous page."},
|
|
18
|
+
},
|
|
19
|
+
"required": [],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
24
|
+
"conversations": [
|
|
25
|
+
{
|
|
26
|
+
"conversation_id": "112992a4-5356-4bd2-8a16-f940cabbb4df",
|
|
27
|
+
"with": "Maya Chen",
|
|
28
|
+
"last_message": "Also, what newsletter tool did you switch from?",
|
|
29
|
+
"last_from_you": True,
|
|
30
|
+
"last_at": "2026-10-18T15:55:35.587Z",
|
|
31
|
+
"unread": 0,
|
|
32
|
+
"is_request": False,
|
|
33
|
+
}
|
|
34
|
+
],
|
|
35
|
+
"next_cursor": None,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@tool(
|
|
40
|
+
name="list_conversations",
|
|
41
|
+
title="List conversations",
|
|
42
|
+
access="read",
|
|
43
|
+
confirm=False,
|
|
44
|
+
description="Your direct-message conversations, latest first, with the last message.",
|
|
45
|
+
details=(
|
|
46
|
+
"Returns your Substack chat inbox: one-to-one and group direct messages with who they are with, the "
|
|
47
|
+
"last message, its time, your unread count and whether it is a message request from someone you do "
|
|
48
|
+
"not follow. Publication chats are left out. Reading the list does not mark anything read. Pass "
|
|
49
|
+
"conversation_id to get_conversation or send_message."
|
|
50
|
+
),
|
|
51
|
+
input_schema=INPUT_SCHEMA,
|
|
52
|
+
route="GET /conversations",
|
|
53
|
+
example_response=EXAMPLE_RESPONSE,
|
|
54
|
+
)
|
|
55
|
+
async def list_conversations(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
56
|
+
|
|
57
|
+
params = {"tab": "unread" if args.get("unread_only") else "all"}
|
|
58
|
+
if args.get("cursor"):
|
|
59
|
+
params["cursor"] = args["cursor"]
|
|
60
|
+
answer = await call(ctx, "GET", f"{WEB}/api/v1/messages/inbox", params=params)
|
|
61
|
+
conversations = []
|
|
62
|
+
for row in answer.get("threads") or []:
|
|
63
|
+
thread = row.get("messageThread") or {}
|
|
64
|
+
if row.get("type") != "direct-message" or not thread.get("id"):
|
|
65
|
+
continue
|
|
66
|
+
recent = row.get("recentMessage") or {}
|
|
67
|
+
conversations.append(
|
|
68
|
+
{
|
|
69
|
+
"conversation_id": thread["id"],
|
|
70
|
+
"with": row.get("title") or thread.get("name") or "",
|
|
71
|
+
"last_message": recent.get("body") or row.get("subtitleBody") or "",
|
|
72
|
+
"last_from_you": row.get("subtitleName") == "You",
|
|
73
|
+
"last_at": row.get("timestamp") or recent.get("created_at"),
|
|
74
|
+
"unread": int(row.get("unreadCount") or 0),
|
|
75
|
+
"is_request": bool(row.get("isRequest")),
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return {"conversations": conversations, "next_cursor": answer.get("nextCursor")}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""List Note replies."""
|
|
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, comment_id, person
|
|
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
|
+
"note_id": {"type": "string", "description": "Note ID from list_notes or search_substack."},
|
|
17
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous page."},
|
|
18
|
+
},
|
|
19
|
+
"required": ["note_id"],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
24
|
+
"replies": [
|
|
25
|
+
{
|
|
26
|
+
"note_id": "364150283",
|
|
27
|
+
"author": {"user_id": "81309935", "name": "Sam Ortiz", "handle": ""},
|
|
28
|
+
"body": "Same here, the phone rule changed everything.",
|
|
29
|
+
"published_at": "2026-10-18T15:55:08.644Z",
|
|
30
|
+
"likes": 2,
|
|
31
|
+
"restacks": 0,
|
|
32
|
+
"replies": 1,
|
|
33
|
+
"url": None,
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"next_cursor": None,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@tool(
|
|
41
|
+
name="list_note_replies",
|
|
42
|
+
title="List Note replies",
|
|
43
|
+
access="read",
|
|
44
|
+
confirm=False,
|
|
45
|
+
description="Replies under a Note, with author, text and likes.",
|
|
46
|
+
details=(
|
|
47
|
+
"Returns the direct replies under one Note (yours or anyone's), in Substack's order. replies is the "
|
|
48
|
+
"count under each reply; list those by passing its note_id. Substack sends no handle with replies, so "
|
|
49
|
+
"author.handle is empty and url is null. Page with next_cursor."
|
|
50
|
+
),
|
|
51
|
+
input_schema=INPUT_SCHEMA,
|
|
52
|
+
route="GET /notes/{note_id}/replies",
|
|
53
|
+
example_response=EXAMPLE_RESPONSE,
|
|
54
|
+
)
|
|
55
|
+
async def list_note_replies(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
56
|
+
|
|
57
|
+
nid = comment_id(args["note_id"], "list_notes")
|
|
58
|
+
params = {"cursor": args["cursor"]} if args.get("cursor") else None
|
|
59
|
+
answer = await call(ctx, "GET", f"{WEB}/api/v1/reader/comment/{nid}/replies", params=params)
|
|
60
|
+
replies = [
|
|
61
|
+
_note(branch["comment"])
|
|
62
|
+
for branch in answer.get("commentBranches") or []
|
|
63
|
+
if (branch.get("comment") or {}).get("id")
|
|
64
|
+
]
|
|
65
|
+
return {"replies": replies, "next_cursor": answer.get("nextCursor")}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _note(comment: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
handle = comment.get("handle") or ""
|
|
70
|
+
return {
|
|
71
|
+
"note_id": str(comment["id"]),
|
|
72
|
+
"author": person({"id": comment.get("user_id"), "name": comment.get("name"), "handle": handle}),
|
|
73
|
+
"body": comment.get("body") or "",
|
|
74
|
+
"published_at": comment.get("date"),
|
|
75
|
+
"likes": int(comment.get("reaction_count") or 0),
|
|
76
|
+
"restacks": int(comment.get("restacks") or 0),
|
|
77
|
+
"replies": int(comment.get("children_count") or 0),
|
|
78
|
+
"url": f"{WEB}/@{handle}/note/c-{comment['id']}" if handle else None,
|
|
79
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""List Notes."""
|
|
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, person, publication, user_ref
|
|
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
|
+
"user": {"type": "string", "description": "Handle (e.g. @mayachen) or user ID. Default: you."},
|
|
17
|
+
"cursor": {"type": "string", "description": "next_cursor from the previous page."},
|
|
18
|
+
},
|
|
19
|
+
"required": [],
|
|
20
|
+
"additionalProperties": False,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
24
|
+
"notes": [
|
|
25
|
+
{
|
|
26
|
+
"note_id": "364150154",
|
|
27
|
+
"author": {"user_id": "561078443", "name": "Maya Chen", "handle": "mayachen"},
|
|
28
|
+
"body": "Day 21 of the early hour. Coffee first, phone last.",
|
|
29
|
+
"published_at": "2026-10-18T15:54:57.801Z",
|
|
30
|
+
"likes": 12,
|
|
31
|
+
"restacks": 1,
|
|
32
|
+
"replies": 3,
|
|
33
|
+
"url": "https://substack.com/@mayachen/note/c-364150154",
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"next_cursor": None,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@tool(
|
|
41
|
+
name="list_notes",
|
|
42
|
+
title="List Notes",
|
|
43
|
+
access="read",
|
|
44
|
+
confirm=False,
|
|
45
|
+
description="Notes posted by you or any other Substack user, newest first, with likes and replies.",
|
|
46
|
+
details=(
|
|
47
|
+
"Returns the Notes on a profile's Notes tab, newest first: text, time, likes, restacks, reply count "
|
|
48
|
+
"and link. Without user it lists yours. Posts and restacks of posts on the profile are left out. "
|
|
49
|
+
"Page with next_cursor. Pass note_id to list_note_replies, like_comment or reply_to_note."
|
|
50
|
+
),
|
|
51
|
+
input_schema=INPUT_SCHEMA,
|
|
52
|
+
route="GET /notes",
|
|
53
|
+
example_response=EXAMPLE_RESPONSE,
|
|
54
|
+
)
|
|
55
|
+
async def list_notes(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
56
|
+
|
|
57
|
+
who = await user_ref(ctx, args["user"]) if args.get("user") else {"id": (await publication(ctx))["user_id"]}
|
|
58
|
+
uid = who["id"]
|
|
59
|
+
params = {"cursor": args["cursor"]} if args.get("cursor") else None
|
|
60
|
+
answer = await call(ctx, "GET", f"{WEB}/api/v1/reader/feed/profile/{uid}", params=params)
|
|
61
|
+
notes = [
|
|
62
|
+
_note(item["comment"])
|
|
63
|
+
for item in answer.get("items") or []
|
|
64
|
+
if item.get("type") == "comment" and (item.get("comment") or {}).get("id")
|
|
65
|
+
]
|
|
66
|
+
return {"notes": notes, "next_cursor": answer.get("nextCursor")}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _note(comment: dict[str, Any]) -> dict[str, Any]:
|
|
70
|
+
handle = comment.get("handle") or ""
|
|
71
|
+
return {
|
|
72
|
+
"note_id": str(comment["id"]),
|
|
73
|
+
"author": person({"id": comment.get("user_id"), "name": comment.get("name"), "handle": handle}),
|
|
74
|
+
"body": comment.get("body") or "",
|
|
75
|
+
"published_at": comment.get("date"),
|
|
76
|
+
"likes": int(comment.get("reaction_count") or 0),
|
|
77
|
+
"restacks": int(comment.get("restacks") or 0),
|
|
78
|
+
"replies": int(comment.get("children_count") or 0),
|
|
79
|
+
"url": f"{WEB}/@{handle}/note/c-{comment['id']}" if handle else None,
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""List post tags."""
|
|
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, publication
|
|
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] = {
|
|
16
|
+
"tags": [{"tag_id": "80ba205f-6de0-4024-860d-0df42720d32a", "name": "Habits", "slug": "habits", "hidden": False}]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@tool(
|
|
21
|
+
name="list_post_tags",
|
|
22
|
+
title="List post tags",
|
|
23
|
+
access="read",
|
|
24
|
+
confirm=False,
|
|
25
|
+
description="Tags your publication uses to group posts.",
|
|
26
|
+
details=(
|
|
27
|
+
"Returns every post tag of your publication with its ID, name and slug; readers see tagged posts on "
|
|
28
|
+
"/t/{slug}. hidden tags group posts without a public page. Pass tag_id to tag_post."
|
|
29
|
+
),
|
|
30
|
+
input_schema=INPUT_SCHEMA,
|
|
31
|
+
route="GET /post-tags",
|
|
32
|
+
example_response=EXAMPLE_RESPONSE,
|
|
33
|
+
)
|
|
34
|
+
async def list_post_tags(ctx: ToolContext, _args: dict[str, Any]) -> Any:
|
|
35
|
+
|
|
36
|
+
pub = await publication(ctx)
|
|
37
|
+
rows = await call(ctx, "GET", f"{pub['base']}/api/v1/publication/post-tag")
|
|
38
|
+
return {
|
|
39
|
+
"tags": [
|
|
40
|
+
{
|
|
41
|
+
"tag_id": str(row["id"]),
|
|
42
|
+
"name": row.get("name") or "",
|
|
43
|
+
"slug": row.get("slug") or "",
|
|
44
|
+
"hidden": bool(row.get("hidden")),
|
|
45
|
+
}
|
|
46
|
+
for row in rows or []
|
|
47
|
+
]
|
|
48
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""List posts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from substack_api_mcp.core.helpers import limit
|
|
8
|
+
from substack_api_mcp.core.tools import tool
|
|
9
|
+
from substack_api_mcp.substack.client import (
|
|
10
|
+
call,
|
|
11
|
+
next_cursor,
|
|
12
|
+
offset_cursor,
|
|
13
|
+
post_status,
|
|
14
|
+
post_url,
|
|
15
|
+
publication,
|
|
16
|
+
scheduled_at,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Status -> dashboard list and its order, as the Posts tabs of the dashboard ask for them.
|
|
20
|
+
_LISTS = {
|
|
21
|
+
"published": ("published", "post_date", "desc"),
|
|
22
|
+
"draft": ("drafts", "draft_updated_at", "desc"),
|
|
23
|
+
"scheduled": ("scheduled", "trigger_at", "asc"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from substack_api_mcp.core.context import ToolContext
|
|
28
|
+
|
|
29
|
+
INPUT_SCHEMA: dict[str, Any] = {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"status": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"enum": ["published", "draft", "scheduled"],
|
|
35
|
+
"description": "published, draft or scheduled. Default: published.",
|
|
36
|
+
},
|
|
37
|
+
"limit": {"type": "integer", "description": "1 to 50. Default: 20."},
|
|
38
|
+
"cursor": {"type": "string", "description": "Next page cursor from the previous call."},
|
|
39
|
+
},
|
|
40
|
+
"required": [],
|
|
41
|
+
"additionalProperties": False,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
45
|
+
"posts": [
|
|
46
|
+
{
|
|
47
|
+
"post_id": "148213977",
|
|
48
|
+
"title": "The quiet launch",
|
|
49
|
+
"status": "published",
|
|
50
|
+
"audience": "everyone",
|
|
51
|
+
"published_at": "2026-10-06T12:00:00Z",
|
|
52
|
+
"url": "https://yourname.substack.com/p/the-quiet-launch",
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"post_id": "148420031",
|
|
56
|
+
"title": "Notes on pricing",
|
|
57
|
+
"status": "draft",
|
|
58
|
+
"audience": "only_paid",
|
|
59
|
+
"published_at": None,
|
|
60
|
+
"url": None,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
"next_cursor": "eyJvZmZzZXQiOjIwfQ",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@tool(
|
|
68
|
+
name="list_posts",
|
|
69
|
+
title="List posts",
|
|
70
|
+
access="read",
|
|
71
|
+
confirm=False,
|
|
72
|
+
description="Posts of your publication, newest first, with status and audience.",
|
|
73
|
+
details=(
|
|
74
|
+
"Lists posts of your publication, newest first. Filter by status to see drafts or scheduled posts. "
|
|
75
|
+
"Scheduled posts include their publish time. Pages hold up to 50 posts; pass next_cursor back as "
|
|
76
|
+
"cursor for the next page."
|
|
77
|
+
),
|
|
78
|
+
input_schema=INPUT_SCHEMA,
|
|
79
|
+
route="GET /posts",
|
|
80
|
+
example_response=EXAMPLE_RESPONSE,
|
|
81
|
+
)
|
|
82
|
+
async def list_posts(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
83
|
+
|
|
84
|
+
pub = await publication(ctx)
|
|
85
|
+
status = args.get("status") or "published"
|
|
86
|
+
endpoint, order_by, direction = _LISTS[status]
|
|
87
|
+
wanted = limit(args, 20, 50)
|
|
88
|
+
offset = offset_cursor(args.get("cursor"))
|
|
89
|
+
params = {"offset": offset, "limit": wanted, "order_by": order_by, "order_direction": direction}
|
|
90
|
+
answer = await call(ctx, "GET", f"{pub['base']}/api/v1/post_management/{endpoint}", params=params)
|
|
91
|
+
rows = answer.get("posts") or []
|
|
92
|
+
posts = []
|
|
93
|
+
for post in rows:
|
|
94
|
+
item = {
|
|
95
|
+
"post_id": str(post["id"]),
|
|
96
|
+
"title": post.get("title") or post.get("draft_title") or "",
|
|
97
|
+
"status": post_status(post),
|
|
98
|
+
"audience": post.get("audience"),
|
|
99
|
+
"published_at": post.get("post_date") if post.get("is_published") else None,
|
|
100
|
+
"url": post_url(pub, post),
|
|
101
|
+
}
|
|
102
|
+
if item["status"] == "scheduled":
|
|
103
|
+
item["publish_at"] = scheduled_at(post)
|
|
104
|
+
posts.append(item)
|
|
105
|
+
return {"posts": posts, "next_cursor": next_cursor(offset, len(rows), wanted)}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""List recommendations."""
|
|
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, 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": {
|
|
16
|
+
"direction": {
|
|
17
|
+
"type": "string",
|
|
18
|
+
"enum": ["given", "received"],
|
|
19
|
+
"description": "given (publications you recommend) or received (that recommend you). Default: given.",
|
|
20
|
+
},
|
|
21
|
+
"publication_id": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Check one publication instead: does yours recommend it (given only).",
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
"required": [],
|
|
27
|
+
"additionalProperties": False,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
EXAMPLE_RESPONSE: dict[str, Any] = {
|
|
31
|
+
"direction": "given",
|
|
32
|
+
"recommendations": [
|
|
33
|
+
{
|
|
34
|
+
"publication_id": "5262902",
|
|
35
|
+
"name": "Brain Health Decoded",
|
|
36
|
+
"blurb": "The best science newsletter I read.",
|
|
37
|
+
"active": True,
|
|
38
|
+
"mutual": None,
|
|
39
|
+
"since": "2026-10-18T16:35:11.246Z",
|
|
40
|
+
"subscribers_sent": 12,
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"counts": {"given": 1, "received": 0, "subscribers_from_recommendations": 0},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@tool(
|
|
48
|
+
name="list_recommendations",
|
|
49
|
+
title="List recommendations",
|
|
50
|
+
access="read",
|
|
51
|
+
confirm=False,
|
|
52
|
+
description="Publications you recommend, or that recommend you, with the subscribers each sent.",
|
|
53
|
+
details=(
|
|
54
|
+
"Returns Audience > Recommendations: with direction given, the publications you recommend; with "
|
|
55
|
+
"received, the ones recommending you, and whether you recommend them back (mutual). "
|
|
56
|
+
"subscribers_sent counts subscribers the recommendation brought. The table is rebuilt by Substack "
|
|
57
|
+
"in the background, so a recommendation made minutes ago may be missing while counts already "
|
|
58
|
+
"include it; publication_id checks one recommendation of yours directly."
|
|
59
|
+
),
|
|
60
|
+
input_schema=INPUT_SCHEMA,
|
|
61
|
+
route="GET /recommendations",
|
|
62
|
+
example_response=EXAMPLE_RESPONSE,
|
|
63
|
+
)
|
|
64
|
+
async def list_recommendations(ctx: ToolContext, args: dict[str, Any]) -> Any:
|
|
65
|
+
|
|
66
|
+
direction = args.get("direction") or "given"
|
|
67
|
+
pub = await publication(ctx)
|
|
68
|
+
base = f"{pub['base']}/api/v1/recommendations"
|
|
69
|
+
exist = await call(ctx, "GET", f"{base}/exist")
|
|
70
|
+
if args.get("publication_id"):
|
|
71
|
+
target = str(args["publication_id"]).strip()
|
|
72
|
+
found = await call(ctx, "GET", f"{base}/from/{pub['id']}/to/{target}") if target.isdigit() else {}
|
|
73
|
+
target_pub = (found or {}).get("recommendedPublication") or {}
|
|
74
|
+
rows = [{**found, "target_pub": {**target_pub, "id": target}, "is_active": True}] if found else []
|
|
75
|
+
direction = "given"
|
|
76
|
+
else:
|
|
77
|
+
rows = await _all(ctx, f"{base}/stats/{'from' if direction == 'given' else 'to'}")
|
|
78
|
+
side = "target_pub" if direction == "given" else "source_pub"
|
|
79
|
+
return {
|
|
80
|
+
"direction": direction,
|
|
81
|
+
"recommendations": [
|
|
82
|
+
{
|
|
83
|
+
"publication_id": str((row.get(side) or {}).get("id") or ""),
|
|
84
|
+
"name": (row.get(side) or {}).get("name") or "",
|
|
85
|
+
"blurb": row.get("description") or row.get("blurb"),
|
|
86
|
+
"active": bool(row.get("is_active")),
|
|
87
|
+
"mutual": row.get("is_mutual") if direction == "received" else None,
|
|
88
|
+
"since": row.get("created_at"),
|
|
89
|
+
"subscribers_sent": int(row.get("xp_signups") or 0),
|
|
90
|
+
}
|
|
91
|
+
for row in rows
|
|
92
|
+
],
|
|
93
|
+
"counts": {
|
|
94
|
+
"given": int(exist.get("recommendingOthers") or 0),
|
|
95
|
+
"received": int(exist.get("beingRecommended") or 0),
|
|
96
|
+
"subscribers_from_recommendations": int(exist.get("totalSignups") or 0),
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _all(ctx: ToolContext, url: str) -> list[dict[str, Any]]:
|
|
102
|
+
"""The dashboard reads the table 20 rows at a time until it has them all."""
|
|
103
|
+
rows: list[dict[str, Any]] = []
|
|
104
|
+
for offset in range(0, 1000, 20):
|
|
105
|
+
page = await call(ctx, "GET", url, params={"offset": offset, "limit": 20})
|
|
106
|
+
rows += page.get("rows") or []
|
|
107
|
+
if offset + 20 >= int(page.get("total") or 0):
|
|
108
|
+
break
|
|
109
|
+
return rows
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""List sections."""
|
|
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, publication
|
|
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] = {
|
|
16
|
+
"sections": [
|
|
17
|
+
{
|
|
18
|
+
"section_id": "482783",
|
|
19
|
+
"name": "Field notes",
|
|
20
|
+
"description": "Short notes from the week.",
|
|
21
|
+
"slug": "field-notes",
|
|
22
|
+
"live": True,
|
|
23
|
+
"subscribed_by_default": True,
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@tool(
|
|
30
|
+
name="list_sections",
|
|
31
|
+
title="List sections",
|
|
32
|
+
access="read",
|
|
33
|
+
confirm=False,
|
|
34
|
+
description="Sections of your publication, the separate newsletters inside it.",
|
|
35
|
+
details=(
|
|
36
|
+
"Returns each section with its ID, name, description and slug. live is false while Substack still "
|
|
37
|
+
"sets a new section up. subscribed_by_default tells whether new subscribers get its emails. A "
|
|
38
|
+
"publication without sections returns an empty list."
|
|
39
|
+
),
|
|
40
|
+
input_schema=INPUT_SCHEMA,
|
|
41
|
+
route="GET /sections",
|
|
42
|
+
example_response=EXAMPLE_RESPONSE,
|
|
43
|
+
)
|
|
44
|
+
async def list_sections(ctx: ToolContext, _args: dict[str, Any]) -> Any:
|
|
45
|
+
|
|
46
|
+
pub = await publication(ctx)
|
|
47
|
+
rows = await call(ctx, "GET", f"{pub['base']}/api/v1/publication/sections")
|
|
48
|
+
return {
|
|
49
|
+
"sections": [
|
|
50
|
+
{
|
|
51
|
+
"section_id": str(row["id"]),
|
|
52
|
+
"name": row.get("name") or "",
|
|
53
|
+
"description": row.get("description") or "",
|
|
54
|
+
"slug": row.get("slug") or "",
|
|
55
|
+
"live": bool(row.get("is_live")),
|
|
56
|
+
"subscribed_by_default": bool(row.get("is_default_on")),
|
|
57
|
+
}
|
|
58
|
+
for row in rows or []
|
|
59
|
+
]
|
|
60
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""List subscriber tags."""
|
|
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, publication
|
|
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] = {"tags": [{"tag_id": "9357", "name": "Early readers"}]}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@tool(
|
|
19
|
+
name="list_subscriber_tags",
|
|
20
|
+
title="List subscriber tags",
|
|
21
|
+
access="read",
|
|
22
|
+
confirm=False,
|
|
23
|
+
description="Tags you use to group subscribers.",
|
|
24
|
+
details=(
|
|
25
|
+
"Returns the subscriber tags of your publication with their IDs, as Audience > Subscribers > Tags "
|
|
26
|
+
"shows them. Subscribers never see tags. Pass tag_id to tag_subscribers."
|
|
27
|
+
),
|
|
28
|
+
input_schema=INPUT_SCHEMA,
|
|
29
|
+
route="GET /subscriber-tags",
|
|
30
|
+
example_response=EXAMPLE_RESPONSE,
|
|
31
|
+
)
|
|
32
|
+
async def list_subscriber_tags(ctx: ToolContext, _args: dict[str, Any]) -> Any:
|
|
33
|
+
|
|
34
|
+
pub = await publication(ctx)
|
|
35
|
+
answer = await call(ctx, "GET", f"{pub['base']}/api/v1/subscriber-tags")
|
|
36
|
+
return {"tags": [{"tag_id": str(row["id"]), "name": row.get("name") or ""} for row in answer.get("tags") or []]}
|