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.
Files changed (78) hide show
  1. substack_api_mcp/__init__.py +28 -0
  2. substack_api_mcp/api.py +213 -0
  3. substack_api_mcp/app.py +38 -0
  4. substack_api_mcp/cli.py +198 -0
  5. substack_api_mcp/core/__init__.py +0 -0
  6. substack_api_mcp/core/account_guard.py +33 -0
  7. substack_api_mcp/core/context.py +175 -0
  8. substack_api_mcp/core/errors.py +78 -0
  9. substack_api_mcp/core/helpers.py +33 -0
  10. substack_api_mcp/core/tools.py +106 -0
  11. substack_api_mcp/mcp_context.py +126 -0
  12. substack_api_mcp/mcp_server.py +67 -0
  13. substack_api_mcp/runner.py +106 -0
  14. substack_api_mcp/scripting.py +73 -0
  15. substack_api_mcp/session.py +197 -0
  16. substack_api_mcp/substack/__init__.py +0 -0
  17. substack_api_mcp/substack/auth.py +26 -0
  18. substack_api_mcp/substack/client.py +172 -0
  19. substack_api_mcp/substack/feed.py +31 -0
  20. substack_api_mcp/substack/markdown.py +239 -0
  21. substack_api_mcp/substack/publishing.py +70 -0
  22. substack_api_mcp/substack/subscribers.py +94 -0
  23. substack_api_mcp/substack/tools/__init__.py +0 -0
  24. substack_api_mcp/substack/tools/add_subscriber.py +60 -0
  25. substack_api_mcp/substack/tools/cancel_scheduled_post.py +54 -0
  26. substack_api_mcp/substack/tools/create_draft.py +75 -0
  27. substack_api_mcp/substack/tools/create_post_tag.py +49 -0
  28. substack_api_mcp/substack/tools/create_subscriber_tag.py +46 -0
  29. substack_api_mcp/substack/tools/delete_comment.py +45 -0
  30. substack_api_mcp/substack/tools/delete_post.py +47 -0
  31. substack_api_mcp/substack/tools/duplicate_post.py +55 -0
  32. substack_api_mcp/substack/tools/export_subscribers.py +42 -0
  33. substack_api_mcp/substack/tools/follow_user.py +46 -0
  34. substack_api_mcp/substack/tools/get_audience_insights.py +93 -0
  35. substack_api_mcp/substack/tools/get_conversation.py +71 -0
  36. substack_api_mcp/substack/tools/get_daily_stats.py +77 -0
  37. substack_api_mcp/substack/tools/get_feed.py +83 -0
  38. substack_api_mcp/substack/tools/get_post.py +75 -0
  39. substack_api_mcp/substack/tools/get_post_engagement.py +68 -0
  40. substack_api_mcp/substack/tools/get_post_stats.py +73 -0
  41. substack_api_mcp/substack/tools/get_post_traffic.py +72 -0
  42. substack_api_mcp/substack/tools/get_publication_stats.py +74 -0
  43. substack_api_mcp/substack/tools/get_unread_counts.py +40 -0
  44. substack_api_mcp/substack/tools/like_comment.py +57 -0
  45. substack_api_mcp/substack/tools/like_post.py +45 -0
  46. substack_api_mcp/substack/tools/list_activity.py +111 -0
  47. substack_api_mcp/substack/tools/list_comments.py +79 -0
  48. substack_api_mcp/substack/tools/list_conversations.py +78 -0
  49. substack_api_mcp/substack/tools/list_note_replies.py +79 -0
  50. substack_api_mcp/substack/tools/list_notes.py +80 -0
  51. substack_api_mcp/substack/tools/list_post_tags.py +48 -0
  52. substack_api_mcp/substack/tools/list_posts.py +105 -0
  53. substack_api_mcp/substack/tools/list_recommendations.py +109 -0
  54. substack_api_mcp/substack/tools/list_sections.py +60 -0
  55. substack_api_mcp/substack/tools/list_subscriber_tags.py +36 -0
  56. substack_api_mcp/substack/tools/list_subscribers.py +82 -0
  57. substack_api_mcp/substack/tools/list_subscriptions.py +76 -0
  58. substack_api_mcp/substack/tools/list_traffic_sources.py +89 -0
  59. substack_api_mcp/substack/tools/pin_comment.py +46 -0
  60. substack_api_mcp/substack/tools/publish_note.py +83 -0
  61. substack_api_mcp/substack/tools/publish_post.py +72 -0
  62. substack_api_mcp/substack/tools/recommend_publication.py +66 -0
  63. substack_api_mcp/substack/tools/remove_comment.py +45 -0
  64. substack_api_mcp/substack/tools/remove_subscriber.py +57 -0
  65. substack_api_mcp/substack/tools/reply_to_comment.py +63 -0
  66. substack_api_mcp/substack/tools/reply_to_note.py +56 -0
  67. substack_api_mcp/substack/tools/schedule_post.py +78 -0
  68. substack_api_mcp/substack/tools/search_substack.py +84 -0
  69. substack_api_mcp/substack/tools/send_message.py +65 -0
  70. substack_api_mcp/substack/tools/tag_post.py +55 -0
  71. substack_api_mcp/substack/tools/tag_subscribers.py +75 -0
  72. substack_api_mcp/substack/tools/unpublish_post.py +50 -0
  73. substack_api_mcp/substack/tools/update_draft.py +93 -0
  74. substack_api_mcp-1.0.2.dist-info/METADATA +516 -0
  75. substack_api_mcp-1.0.2.dist-info/RECORD +78 -0
  76. substack_api_mcp-1.0.2.dist-info/WHEEL +4 -0
  77. substack_api_mcp-1.0.2.dist-info/entry_points.txt +2 -0
  78. substack_api_mcp-1.0.2.dist-info/licenses/LICENSE +133 -0
@@ -0,0 +1,63 @@
1
+ """Reply to 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 call, 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."},
18
+ "body": {"type": "string", "description": "Reply text."},
19
+ },
20
+ "required": ["comment_id", "body"],
21
+ "additionalProperties": False,
22
+ }
23
+
24
+ EXAMPLE_RESPONSE: dict[str, Any] = {
25
+ "comment_id": "363485182",
26
+ "parent_id": "363478059",
27
+ "created_at": "2026-10-11T10:07:00Z",
28
+ }
29
+
30
+
31
+ @tool(
32
+ name="reply_to_comment",
33
+ title="Reply to comment",
34
+ access="write",
35
+ confirm=True,
36
+ description="Reply as you under a comment on your post.",
37
+ details=(
38
+ "Posts a reply under one comment on a post of your publication, under your name, visible to everyone "
39
+ "who can read the post. A comment elsewhere on Substack returns an error. The author gets Substack's "
40
+ "usual reply notification. By default you confirm each call; you can switch that off for this tool."
41
+ ),
42
+ input_schema=INPUT_SCHEMA,
43
+ route="POST /comments/{comment_id}/replies",
44
+ example_response=EXAMPLE_RESPONSE,
45
+ )
46
+ async def reply_to_comment(ctx: ToolContext, args: dict[str, Any]) -> Any:
47
+
48
+ comment_id = str(args["comment_id"]).strip().removeprefix("c-")
49
+ if not comment_id.isdigit():
50
+ raise ToolError("comment_id must be the ID from list_comments.", ErrorCode.INVALID_INPUT)
51
+ text = args["body"].strip()
52
+ if not text:
53
+ raise ToolError("body must not be empty.", ErrorCode.INVALID_INPUT)
54
+ pub = await publication(ctx)
55
+ found = await call(ctx, "GET", f"{pub['base']}/api/v1/reader/comment/{comment_id}")
56
+ parent = (found.get("item") or {}).get("comment") or {}
57
+ if not parent.get("post_id"):
58
+ raise ToolError("Substack has no comment with this ID.", ErrorCode.TARGET_ERROR)
59
+ if parent.get("publication_id") != pub["id"]:
60
+ raise ToolError("This comment is not on a post of your publication.", ErrorCode.APP_PERMISSION_DENIED)
61
+ body = {"body": text, "parent_id": int(comment_id)}
62
+ reply = await call(ctx, "POST", f"{pub['base']}/api/v1/post/{parent['post_id']}/comment", body=body)
63
+ return {"comment_id": str(reply["id"]), "parent_id": comment_id, "created_at": reply.get("date")}
@@ -0,0 +1,56 @@
1
+ """Reply to Note."""
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
10
+ from substack_api_mcp.substack.markdown import to_note_doc
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
+ "note_id": {"type": "string", "description": "Note ID from list_notes, list_note_replies or search_substack."},
19
+ "body": {"type": "string", "description": "Reply text, up to 1,000 characters."},
20
+ },
21
+ "required": ["note_id", "body"],
22
+ "additionalProperties": False,
23
+ }
24
+
25
+ EXAMPLE_RESPONSE: dict[str, Any] = {
26
+ "note_id": "364150283",
27
+ "parent_id": "364150154",
28
+ "published_at": "2026-10-18T15:55:08.644Z",
29
+ }
30
+
31
+
32
+ @tool(
33
+ name="reply_to_note",
34
+ title="Reply to Note",
35
+ access="write",
36
+ confirm=True,
37
+ description="Reply as you under any Note or Note reply.",
38
+ details=(
39
+ "Posts a reply under a Note (yours or anyone's) or under a reply to one, visible to everyone who "
40
+ "sees the Note. Text is 1 to 1,000 characters; Markdown bold, italics and links work. The author gets "
41
+ "Substack's usual notification. By default you confirm each call; you can switch that off for this "
42
+ "tool."
43
+ ),
44
+ input_schema=INPUT_SCHEMA,
45
+ route="POST /notes/{note_id}/replies",
46
+ example_response=EXAMPLE_RESPONSE,
47
+ )
48
+ async def reply_to_note(ctx: ToolContext, args: dict[str, Any]) -> Any:
49
+
50
+ parent = comment_id(args["note_id"], "list_notes")
51
+ text = str(args["body"]).strip()
52
+ if not text or len(text) > 1000:
53
+ raise ToolError("body must be 1 to 1,000 characters.", ErrorCode.INVALID_INPUT)
54
+ body = {"bodyJson": to_note_doc(text), "parent_id": parent, "replyMinimumRole": "everyone"}
55
+ reply = await call(ctx, "POST", f"{WEB}/api/v1/comment/feed", body=body)
56
+ return {"note_id": str(reply["id"]), "parent_id": str(parent), "published_at": reply.get("date")}
@@ -0,0 +1,78 @@
1
+ """Schedule 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 call, draft, post_id, publication, require_draft
9
+ from substack_api_mcp.substack.publishing import check_ready, future_time, set_options
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
+ "post_id": {"type": "string", "description": "Post ID from list_posts."},
18
+ "publish_at": {"type": "string", "format": "date-time", "description": "ISO 8601 with time zone."},
19
+ "send_email": {"type": "boolean", "description": "Email it to subscribers. Default: true."},
20
+ "section_id": {
21
+ "type": "string",
22
+ "description": "Section ID from list_sections, or main. Default: the draft's section, else main.",
23
+ },
24
+ },
25
+ "required": ["post_id", "publish_at"],
26
+ "additionalProperties": False,
27
+ }
28
+
29
+ EXAMPLE_RESPONSE: dict[str, Any] = {
30
+ "post_id": "148420031",
31
+ "status": "scheduled",
32
+ "publish_at": "2026-10-13T08:00:00-04:00",
33
+ "audience": "everyone",
34
+ "url": "https://yourname.substack.com/p/notes-on-pricing",
35
+ }
36
+
37
+
38
+ @tool(
39
+ name="schedule_post",
40
+ title="Schedule post",
41
+ access="write",
42
+ confirm=True,
43
+ description="Publish a draft at a set time, on the web and by email.",
44
+ details=(
45
+ "Schedules a draft to go live at the time you set, on the web and, unless send_email is false, by email, "
46
+ "to the audience set on the draft. The time needs a time zone and must be in the future. Substack's own "
47
+ "pre-publish check runs first; a missing title or an empty body stops the call. A scheduled post stays "
48
+ "editable until it goes out. On a publication with sections the post goes to section_id, else to the "
49
+ "section already set on the draft, else to the main publication. By default you confirm each call; you "
50
+ "can switch that off for this tool."
51
+ ),
52
+ input_schema=INPUT_SCHEMA,
53
+ route="PUT /posts/{post_id}/schedule",
54
+ example_response=EXAMPLE_RESPONSE,
55
+ )
56
+ async def schedule_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
57
+
58
+ pub = await publication(ctx)
59
+ pid = post_id(args["post_id"])
60
+ when = future_time(args["publish_at"])
61
+ post = await draft(ctx, pub, pid)
62
+ require_draft(post)
63
+ send_email = args.get("send_email", True)
64
+ await set_options(ctx, pub, post, send_email, args.get("section_id"))
65
+ trigger_at = when.strftime("%Y-%m-%dT%H:%M:%S.000Z")
66
+ await check_ready(ctx, pub, pid, trigger_at)
67
+ audience = post.get("audience") or "everyone"
68
+ release = {"trigger_at": trigger_at, "post_audience": audience, "email_audience": audience}
69
+ await call(ctx, "POST", f"{pub['base']}/api/v1/drafts/{pid}/scheduled_release", body=release)
70
+ scheduled = await draft(ctx, pub, pid)
71
+ slug = scheduled.get("slug")
72
+ return {
73
+ "post_id": str(pid),
74
+ "status": "scheduled",
75
+ "publish_at": args["publish_at"],
76
+ "audience": audience,
77
+ "url": f"{pub['base']}/p/{slug}" if slug else None,
78
+ }
@@ -0,0 +1,84 @@
1
+ """Search Substack."""
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, person
10
+ from substack_api_mcp.substack.feed import note_item, post_item
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
+ "query": {"type": "string", "description": "Words to search for."},
19
+ "cursor": {"type": "string", "description": "next_cursor from the previous page."},
20
+ },
21
+ "required": ["query"],
22
+ "additionalProperties": False,
23
+ }
24
+
25
+ EXAMPLE_RESPONSE: dict[str, Any] = {
26
+ "people": [{"user_id": "528339521", "name": "Routine Morning", "handle": "routinemorning", "bio": "Small habits."}],
27
+ "posts": [
28
+ {
29
+ "post_id": "197396217",
30
+ "title": "A Guide to Your Morning Routine",
31
+ "publication": "Brain Health Decoded",
32
+ "publication_id": "5262902",
33
+ "published_at": "2026-05-12T19:25:47.700Z",
34
+ "likes": 410,
35
+ "url": "https://example.substack.com/p/a-guide-to-your-morning-routine",
36
+ }
37
+ ],
38
+ "notes": [
39
+ {
40
+ "note_id": "322981144",
41
+ "author": {"user_id": "18270011", "name": "Laura Mills", "handle": "lauramills"},
42
+ "body": "Morning light beats any supplement.",
43
+ "published_at": "2026-09-02T07:11:00.000Z",
44
+ "likes": 88,
45
+ }
46
+ ],
47
+ "next_cursor": "eyJvZmZzZXQiOjIwfQ==",
48
+ }
49
+
50
+
51
+ @tool(
52
+ name="search_substack",
53
+ title="Search Substack",
54
+ access="read",
55
+ confirm=False,
56
+ description="Search all of Substack for people, posts and Notes.",
57
+ details=(
58
+ "Runs Substack's search as the search box does and splits the results into people, posts and Notes. "
59
+ "Use the IDs with follow_user, like_post, like_comment, reply_to_note, send_message or "
60
+ "recommend_publication. Page with next_cursor; later pages may hold only posts and Notes."
61
+ ),
62
+ input_schema=INPUT_SCHEMA,
63
+ route="GET /search",
64
+ example_response=EXAMPLE_RESPONSE,
65
+ )
66
+ async def search_substack(ctx: ToolContext, args: dict[str, Any]) -> Any:
67
+
68
+ query = str(args["query"]).strip()
69
+ if not query:
70
+ raise ToolError("query must not be empty.", ErrorCode.INVALID_INPUT)
71
+ params = {"query": query, **({"cursor": args["cursor"]} if args.get("cursor") else {})}
72
+ answer = await call(ctx, "GET", f"{WEB}/api/v1/top/search", params=params)
73
+ people: list[dict[str, Any]] = []
74
+ posts: list[dict[str, Any]] = []
75
+ notes: list[dict[str, Any]] = []
76
+ for item in answer.get("items") or []:
77
+ kind = item.get("type")
78
+ if kind == "profileSearchResults":
79
+ people += [{**person(row), "bio": row.get("bio") or ""} for row in item.get("results") or []]
80
+ elif kind == "post" and item.get("post"):
81
+ posts.append(post_item(item))
82
+ elif kind == "comment" and item.get("comment"):
83
+ notes.append(note_item(item))
84
+ return {"people": people, "posts": posts, "notes": notes, "next_cursor": answer.get("nextCursor")}
@@ -0,0 +1,65 @@
1
+ """Send message."""
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, user_ref
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
+ "conversation_id": {"type": "string", "description": "Reply in this conversation from list_conversations."},
18
+ "user": {"type": "string", "description": "Or start one with this handle (e.g. @mayachen) or user ID."},
19
+ "body": {"type": "string", "description": "Message text."},
20
+ },
21
+ "required": ["body"],
22
+ "additionalProperties": False,
23
+ }
24
+
25
+ EXAMPLE_RESPONSE: dict[str, Any] = {
26
+ "conversation_id": "112992a4-5356-4bd2-8a16-f940cabbb4df",
27
+ "sent_at": "2026-10-18T15:55:35.587Z",
28
+ }
29
+
30
+
31
+ @tool(
32
+ name="send_message",
33
+ title="Send message",
34
+ access="write",
35
+ confirm=True,
36
+ description="Send a direct message on Substack, in a conversation or to a person.",
37
+ details=(
38
+ "Sends a private chat message under your name. With conversation_id it replies there; with user it "
39
+ "writes to that person, in your existing conversation with them or a new one. Someone who does not "
40
+ "follow you gets it as a message request. Give one of conversation_id or user. A sent message cannot "
41
+ "be recalled. By default you confirm each call; you can switch that off for this tool."
42
+ ),
43
+ input_schema=INPUT_SCHEMA,
44
+ route="POST /messages",
45
+ example_response=EXAMPLE_RESPONSE,
46
+ )
47
+ async def send_message(ctx: ToolContext, args: dict[str, Any]) -> Any:
48
+
49
+ text = str(args["body"]).strip()
50
+ if not text:
51
+ raise ToolError("body must not be empty.", ErrorCode.INVALID_INPUT)
52
+ if bool(args.get("conversation_id")) == bool(args.get("user")):
53
+ raise ToolError("Give one of conversation_id or user.", ErrorCode.INVALID_INPUT)
54
+ if args.get("conversation_id"):
55
+ thread_id = str(args["conversation_id"]).strip().removeprefix("direct-message-")
56
+ if "/" in thread_id:
57
+ raise ToolError("conversation_id must be the ID from list_conversations.", ErrorCode.INVALID_INPUT)
58
+ answer = await call(ctx, "POST", f"{WEB}/api/v1/messages/dm/{thread_id}", body={"body": text})
59
+ else:
60
+ user = await user_ref(ctx, args["user"])
61
+ answer = await call(ctx, "POST", f"{WEB}/api/v1/messages/dm", body={"user_id": user["id"], "body": text})
62
+ thread = answer.get("thread") or {}
63
+ reply = answer.get("reply") or {}
64
+ sent = (reply.get("comment") or reply).get("created_at")
65
+ return {"conversation_id": thread.get("id"), "sent_at": sent or thread.get("updated_at")}
@@ -0,0 +1,55 @@
1
+ """Tag post."""
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": {
17
+ "post_id": {"type": "string", "description": "Post ID from list_posts."},
18
+ "tag_id": {"type": "string", "description": "Tag ID from list_post_tags or create_post_tag."},
19
+ "remove": {"type": "boolean", "description": "Take the tag off instead. Default: false."},
20
+ },
21
+ "required": ["post_id", "tag_id"],
22
+ "additionalProperties": False,
23
+ }
24
+
25
+ EXAMPLE_RESPONSE: dict[str, Any] = {
26
+ "post_id": "148213977",
27
+ "tag_id": "80ba205f-6de0-4024-860d-0df42720d32a",
28
+ "tagged": True,
29
+ }
30
+
31
+
32
+ @tool(
33
+ name="tag_post",
34
+ title="Tag post",
35
+ access="write",
36
+ confirm=False,
37
+ description="Add a tag to a post, or take it off.",
38
+ details=(
39
+ "Puts one of your post tags on a draft or published post, or removes it with remove. A post can have "
40
+ "several tags. Tagging twice keeps one tag."
41
+ ),
42
+ input_schema=INPUT_SCHEMA,
43
+ route="PUT /posts/{post_id}/tags/{tag_id}",
44
+ example_response=EXAMPLE_RESPONSE,
45
+ )
46
+ async def tag_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
47
+
48
+ tag_id = str(args["tag_id"]).strip()
49
+ if not tag_id or "/" in tag_id:
50
+ raise ToolError("tag_id must be the ID from list_post_tags.", ErrorCode.INVALID_INPUT)
51
+ pub = await publication(ctx)
52
+ pid = post_id(args["post_id"])
53
+ remove = bool(args.get("remove"))
54
+ await call(ctx, "DELETE" if remove else "POST", f"{pub['base']}/api/v1/post/{pid}/tag/{tag_id}")
55
+ return {"post_id": str(pid), "tag_id": tag_id, "tagged": not remove}
@@ -0,0 +1,75 @@
1
+ """Tag subscribers."""
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, publication
10
+ from substack_api_mcp.substack.subscribers import find_subscriber
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
+ "tag_id": {"type": "string", "description": "Tag ID from list_subscriber_tags or create_subscriber_tag."},
19
+ "emails": {
20
+ "type": "array",
21
+ "items": {"type": "string"},
22
+ "minItems": 1,
23
+ "maxItems": 50,
24
+ "description": "Subscribers' email addresses, up to 50.",
25
+ },
26
+ "remove": {"type": "boolean", "description": "Take the tag off instead. Default: false."},
27
+ },
28
+ "required": ["tag_id", "emails"],
29
+ "additionalProperties": False,
30
+ }
31
+
32
+ EXAMPLE_RESPONSE: dict[str, Any] = {
33
+ "tag_id": "9357",
34
+ "tagged": True,
35
+ "updated": ["reader@example.com"],
36
+ "not_found": ["someone@example.com"],
37
+ }
38
+
39
+
40
+ @tool(
41
+ name="tag_subscribers",
42
+ title="Tag subscribers",
43
+ access="write",
44
+ confirm=False,
45
+ description="Put a tag on subscribers by email, or take it off.",
46
+ details=(
47
+ "Adds one of your subscriber tags to up to 50 subscribers, or removes it with remove. Emails not on "
48
+ "your list come back in not_found and nothing else fails. A subscriber added a minute ago may not be "
49
+ "findable yet; try again shortly."
50
+ ),
51
+ input_schema=INPUT_SCHEMA,
52
+ route="POST /subscriber-tags/{tag_id}/subscribers",
53
+ example_response=EXAMPLE_RESPONSE,
54
+ )
55
+ async def tag_subscribers(ctx: ToolContext, args: dict[str, Any]) -> Any:
56
+
57
+ tag_id = str(args["tag_id"]).strip()
58
+ if not tag_id.isdigit():
59
+ raise ToolError("tag_id must be the ID from list_subscriber_tags.", ErrorCode.INVALID_INPUT)
60
+ pub = await publication(ctx)
61
+ updated, missing, ids = [], [], []
62
+ for email in dict.fromkeys(str(email).strip() for email in args["emails"]):
63
+ row = await find_subscriber(ctx, pub, email) if email else None
64
+ if row and row.get("subscription_id"):
65
+ ids.append(row["subscription_id"])
66
+ updated.append(row["user_email_address"])
67
+ else:
68
+ missing.append(email)
69
+ remove = bool(args.get("remove"))
70
+ if ids:
71
+ action = "unassign" if remove else "assign"
72
+ await call(
73
+ ctx, "POST", f"{pub['base']}/api/v1/subscriber-tags/{tag_id}/{action}", body={"subscriptionIds": ids}
74
+ )
75
+ return {"tag_id": tag_id, "tagged": not remove, "updated": updated, "not_found": missing}
@@ -0,0 +1,50 @@
1
+ """Unpublish post."""
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, draft, 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": {
17
+ "post_id": {"type": "string", "description": "Post ID from list_posts."},
18
+ },
19
+ "required": ["post_id"],
20
+ "additionalProperties": False,
21
+ }
22
+
23
+ EXAMPLE_RESPONSE: dict[str, Any] = {"post_id": "148213977", "status": "draft"}
24
+
25
+
26
+ @tool(
27
+ name="unpublish_post",
28
+ title="Unpublish post",
29
+ access="write",
30
+ confirm=True,
31
+ description="Take a published post off the web and back to drafts.",
32
+ details=(
33
+ "Turns a published post back into a draft, as Unpublish in the editor does: it leaves your site, "
34
+ "archive and the Substack app, and its link stops working. Comments and stats stay with the draft. "
35
+ "Emails already sent stay in inboxes. Publish it again with publish_post. A post that is not published "
36
+ "returns an error. By default you confirm each call; you can switch that off for this tool."
37
+ ),
38
+ input_schema=INPUT_SCHEMA,
39
+ route="DELETE /posts/{post_id}/publish",
40
+ example_response=EXAMPLE_RESPONSE,
41
+ )
42
+ async def unpublish_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
43
+
44
+ pub = await publication(ctx)
45
+ pid = post_id(args["post_id"])
46
+ post = await draft(ctx, pub, pid)
47
+ if not post.get("is_published"):
48
+ raise ToolError("This post is not published.", ErrorCode.INVALID_INPUT)
49
+ await call(ctx, "POST", f"{pub['base']}/api/v1/drafts/{pid}/unpublish", body={})
50
+ return {"post_id": str(pid), "status": "draft"}
@@ -0,0 +1,93 @@
1
+ """Update draft."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
9
+ from substack_api_mcp.core.tools import tool
10
+ from substack_api_mcp.substack.client import byline, call, draft, post_id, publication, require_draft
11
+ from substack_api_mcp.substack.markdown import to_doc
12
+
13
+ _FIELDS = ("title", "subtitle", "body_markdown", "audience")
14
+ _AUDIENCES = ("everyone", "only_paid", "founding")
15
+
16
+ if TYPE_CHECKING:
17
+ from substack_api_mcp.core.context import ToolContext
18
+
19
+ INPUT_SCHEMA: dict[str, Any] = {
20
+ "type": "object",
21
+ "properties": {
22
+ "post_id": {"type": "string", "description": "Post ID from list_posts."},
23
+ "fields": {
24
+ "type": "string",
25
+ "description": (
26
+ 'JSON object with any of title, subtitle, body_markdown, audience, e.g. {"title": "New title"}.'
27
+ ),
28
+ },
29
+ },
30
+ "required": ["post_id", "fields"],
31
+ "additionalProperties": False,
32
+ }
33
+
34
+ EXAMPLE_RESPONSE: dict[str, Any] = {"post_id": "148420031", "status": "draft", "updated": ["title", "body_markdown"]}
35
+
36
+
37
+ @tool(
38
+ name="update_draft",
39
+ title="Update draft",
40
+ access="write",
41
+ confirm=False,
42
+ description="Change the title, subtitle, body or audience of a draft.",
43
+ details=(
44
+ "Changes the fields you pass and leaves the others as they are. body_markdown replaces the whole "
45
+ "body. Drafts and scheduled posts only: a published post returns an error. Nothing reaches your "
46
+ "subscribers, so it runs without asking."
47
+ ),
48
+ input_schema=INPUT_SCHEMA,
49
+ route="PATCH /drafts/{post_id}",
50
+ example_response=EXAMPLE_RESPONSE,
51
+ )
52
+ async def update_draft(ctx: ToolContext, args: dict[str, Any]) -> Any:
53
+
54
+ fields = _fields(args["fields"])
55
+ pub = await publication(ctx)
56
+ pid = post_id(args["post_id"])
57
+ post = await draft(ctx, pub, pid)
58
+ require_draft(post)
59
+ body = {
60
+ "draft_title": fields.get("title", post.get("draft_title") or ""),
61
+ "draft_subtitle": fields.get("subtitle", post.get("draft_subtitle") or ""),
62
+ "draft_body": json.dumps(to_doc(fields["body_markdown"]))
63
+ if "body_markdown" in fields
64
+ else post.get("draft_body"),
65
+ "draft_bylines": byline(pub),
66
+ "audience": fields.get("audience", post.get("audience") or "everyone"),
67
+ "last_updated_at": post.get("draft_updated_at"),
68
+ }
69
+ await call(ctx, "PUT", f"{pub['base']}/api/v1/drafts/{pid}", body=body)
70
+ return {"post_id": str(pid), "status": "draft", "updated": [name for name in _FIELDS if name in fields]}
71
+
72
+
73
+ def _fields(value: Any) -> dict[str, Any]:
74
+ """`fields` is a JSON object with any of title, subtitle, body_markdown, audience."""
75
+ parsed = value if isinstance(value, dict) else None
76
+ if isinstance(value, str):
77
+ try:
78
+ parsed = json.loads(value)
79
+ except ValueError:
80
+ parsed = None
81
+ if not isinstance(parsed, dict) or not parsed:
82
+ raise ToolError(
83
+ 'fields must be a JSON object, e.g. {"title": "...", "body_markdown": "..."}.', ErrorCode.INVALID_INPUT
84
+ )
85
+ if unknown := sorted(set(parsed) - set(_FIELDS)):
86
+ raise ToolError(
87
+ f"Unknown fields: {', '.join(unknown)}. Allowed: {', '.join(_FIELDS)}.", ErrorCode.INVALID_INPUT
88
+ )
89
+ if "audience" in parsed and parsed["audience"] not in _AUDIENCES:
90
+ raise ToolError(f"audience must be one of {', '.join(_AUDIENCES)}.", ErrorCode.INVALID_INPUT)
91
+ if any(not isinstance(text, str) for text in parsed.values()):
92
+ raise ToolError("Every field value must be text.", ErrorCode.INVALID_INPUT)
93
+ return parsed