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,82 @@
1
+ """List subscribers."""
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 next_cursor, offset_cursor, publication
10
+ from substack_api_mcp.substack.subscribers import plan_of, subscriber_page
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
+ "plan": {
19
+ "type": "string",
20
+ "enum": ["all", "free", "paid", "comp", "founding"],
21
+ "description": "all, free, paid, comp or founding. Default: all.",
22
+ },
23
+ "since": {
24
+ "type": "string",
25
+ "format": "date",
26
+ "description": "Only subscribers who joined on or after this date.",
27
+ },
28
+ "limit": {"type": "integer", "description": "1 to 500. Default: 100."},
29
+ "cursor": {"type": "string", "description": "Next page cursor from the previous call."},
30
+ },
31
+ "required": [],
32
+ "additionalProperties": False,
33
+ }
34
+
35
+ EXAMPLE_RESPONSE: dict[str, Any] = {
36
+ "subscribers": [
37
+ {"email": "maya@example.com", "plan": "free", "joined_at": "2026-10-04T09:12:00Z", "activity_rating": 4},
38
+ {"email": "jon@example.com", "plan": "paid", "joined_at": "2026-10-02T17:40:00Z", "activity_rating": 5},
39
+ ],
40
+ "next_cursor": None,
41
+ }
42
+
43
+
44
+ @tool(
45
+ name="list_subscribers",
46
+ title="List subscribers",
47
+ access="read",
48
+ confirm=False,
49
+ description="Subscribers with plan, start date and activity rating.",
50
+ details=(
51
+ "Lists subscribers, newest first, from the subscriber dashboard. Activity rating is the 0 to 5 score "
52
+ "Substack shows there. Pages hold up to 500 rows; pass next_cursor back as cursor. The plan and since "
53
+ "filters apply inside each page, so a filtered page can be short or empty while next_cursor still "
54
+ "points further. Someone added a minute ago may not show yet: Substack fills the table in the "
55
+ "background. paid includes founding members."
56
+ ),
57
+ input_schema=INPUT_SCHEMA,
58
+ route="GET /subscribers",
59
+ example_response=EXAMPLE_RESPONSE,
60
+ )
61
+ async def list_subscribers(ctx: ToolContext, args: dict[str, Any]) -> Any:
62
+
63
+ pub = await publication(ctx)
64
+ wanted = limit(args, 100, 500)
65
+ offset = offset_cursor(args.get("cursor"))
66
+ plan = args.get("plan") or "all"
67
+ since = args.get("since")
68
+ rows = await subscriber_page(ctx, pub, offset, wanted, since)
69
+ subscribers = []
70
+ for row in rows:
71
+ row_plan = plan_of(row)
72
+ if plan != "all" and row_plan != plan and not (plan == "paid" and row_plan == "founding"):
73
+ continue
74
+ subscribers.append(
75
+ {
76
+ "email": row.get("user_email_address"),
77
+ "plan": row_plan,
78
+ "joined_at": row.get("subscription_created_at"),
79
+ "activity_rating": row.get("activity_rating"),
80
+ }
81
+ )
82
+ return {"subscribers": subscribers, "next_cursor": next_cursor(offset, len(rows), wanted)}
@@ -0,0 +1,76 @@
1
+ """List subscriptions."""
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] = {
16
+ "subscriptions": [
17
+ {
18
+ "publication_id": "5262902",
19
+ "name": "Brain Health Decoded",
20
+ "url": "https://example.substack.com",
21
+ "plan": "free",
22
+ "emails": True,
23
+ "since": "2026-10-18T13:46:36.900Z",
24
+ }
25
+ ]
26
+ }
27
+
28
+
29
+ @tool(
30
+ name="list_subscriptions",
31
+ title="List subscriptions",
32
+ access="read",
33
+ confirm=False,
34
+ description="Publications you subscribe to, with your plan and whether their emails reach you.",
35
+ details=(
36
+ "Returns every publication your account subscribes to, as Settings > Subscriptions lists them: name, "
37
+ "link, plan (free, paid, comp, founding or gift, as Substack names the membership) and whether you "
38
+ "get its emails. Your own publications are left out."
39
+ ),
40
+ input_schema=INPUT_SCHEMA,
41
+ route="GET /subscriptions",
42
+ example_response=EXAMPLE_RESPONSE,
43
+ )
44
+ async def list_subscriptions(ctx: ToolContext, _args: dict[str, Any]) -> Any:
45
+
46
+ answer = await call(ctx, "GET", f"{WEB}/api/v1/subscriptions/page_v2")
47
+ pubs = {pub.get("id"): pub for pub in answer.get("publications") or []}
48
+ own = {row.get("publication_id") for row in answer.get("publicationUsers") or []}
49
+ subscriptions = []
50
+ for row in answer.get("subscriptions") or []:
51
+ pub = pubs.get(row.get("publication_id")) or {}
52
+ if row.get("publication_id") in own:
53
+ continue
54
+ domain = pub.get("custom_domain") or (f"{pub['subdomain']}.substack.com" if pub.get("subdomain") else None)
55
+ subscriptions.append(
56
+ {
57
+ "publication_id": str(row.get("publication_id") or ""),
58
+ "name": pub.get("name") or "",
59
+ "url": f"https://{domain}" if domain else None,
60
+ "plan": _plan(row),
61
+ "emails": not row.get("email_disabled"),
62
+ "since": row.get("created_at"),
63
+ }
64
+ )
65
+ return {"subscriptions": subscriptions}
66
+
67
+
68
+ def _plan(row: dict[str, Any]) -> str:
69
+ if row.get("gift_user_id"):
70
+ return "gift"
71
+ if row.get("is_founding"):
72
+ return "founding"
73
+ state = str(row.get("membership_state") or "")
74
+ if state == "free_signup":
75
+ return "free"
76
+ return "comp" if "comp" in state else "paid" if state else "free"
@@ -0,0 +1,89 @@
1
+ """List traffic sources."""
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.helpers import limit
10
+ from substack_api_mcp.core.tools import tool
11
+ from substack_api_mcp.substack.client import call, next_cursor, offset_cursor, publication
12
+
13
+ if TYPE_CHECKING:
14
+ from substack_api_mcp.core.context import ToolContext
15
+
16
+ INPUT_SCHEMA: dict[str, Any] = {
17
+ "type": "object",
18
+ "properties": {
19
+ "period": {"type": "string", "enum": ["7d", "30d", "90d", "365d"], "description": "Default: 30d."},
20
+ "limit": {"type": "integer", "minimum": 1, "maximum": 50, "description": "Default: 20."},
21
+ "cursor": {"type": "string", "description": "next_cursor from the previous page."},
22
+ },
23
+ "required": [],
24
+ "additionalProperties": False,
25
+ }
26
+
27
+ EXAMPLE_RESPONSE: dict[str, Any] = {
28
+ "period": "30d",
29
+ "sources": [
30
+ {
31
+ "source": "substack app",
32
+ "category": "Substack",
33
+ "views": 8120,
34
+ "visitors": 3011,
35
+ "free_signups": 41,
36
+ "paid_subscriptions": 3,
37
+ }
38
+ ],
39
+ "next_cursor": None,
40
+ }
41
+
42
+
43
+ @tool(
44
+ name="list_traffic_sources",
45
+ title="List traffic sources",
46
+ access="read",
47
+ confirm=False,
48
+ description="Where your publication's visitors came from in a period, most views first.",
49
+ details=(
50
+ "Returns Analytics > Traffic > Traffic sources: each source (the app, email, a website, search, "
51
+ "social) with its category, views, unique visitors and the free and paid subscribers it brought. "
52
+ "Sorted by views; page with next_cursor."
53
+ ),
54
+ input_schema=INPUT_SCHEMA,
55
+ route="GET /traffic-sources",
56
+ example_response=EXAMPLE_RESPONSE,
57
+ )
58
+ async def list_traffic_sources(ctx: ToolContext, args: dict[str, Any]) -> Any:
59
+
60
+ period = args.get("period") or "30d"
61
+ wanted = limit(args, 20, 50) # Substack answers 400 above 50 (22.10.2026)
62
+ offset = offset_cursor(args.get("cursor"))
63
+ pub = await publication(ctx)
64
+ today = datetime.now(ZoneInfo("Europe/Warsaw")).date()
65
+ params = {
66
+ "from_date": (today - timedelta(days=int(period[:-1]))).isoformat(),
67
+ "to_date": today.isoformat(),
68
+ "order_by": "views",
69
+ "order_direction": "desc",
70
+ "offset": offset,
71
+ "limit": wanted,
72
+ }
73
+ answer = await call(ctx, "GET", f"{pub['base']}/api/v1/publication/stats/visitor_sources", params=params)
74
+ rows = answer.get("rows") or []
75
+ return {
76
+ "period": period,
77
+ "sources": [
78
+ {
79
+ "source": row.get("source") or "",
80
+ "category": row.get("source_category") or "",
81
+ "views": int(row.get("views") or 0),
82
+ "visitors": int(row.get("users") or 0),
83
+ "free_signups": int(row.get("free_signup") or 0),
84
+ "paid_subscriptions": int(row.get("subscribed") or 0),
85
+ }
86
+ for row in rows
87
+ ],
88
+ "next_cursor": next_cursor(offset, len(rows), wanted),
89
+ }
@@ -0,0 +1,46 @@
1
+ """Pin comment."""
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, comment_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
+ "comment_id": {"type": "string", "description": "Comment ID from list_comments."},
17
+ "unpin": {"type": "boolean", "description": "Unpin instead. Default: false."},
18
+ },
19
+ "required": ["comment_id"],
20
+ "additionalProperties": False,
21
+ }
22
+
23
+ EXAMPLE_RESPONSE: dict[str, Any] = {"comment_id": "364149469", "pinned": True}
24
+
25
+
26
+ @tool(
27
+ name="pin_comment",
28
+ title="Pin comment",
29
+ access="write",
30
+ confirm=False,
31
+ description="Pin a comment to the top of your post's comments, or unpin it.",
32
+ details=(
33
+ "Pins one comment on a post of your publication above all others, as Pin in the comment menu does, "
34
+ "or unpins it. A comment on someone else's post returns an error."
35
+ ),
36
+ input_schema=INPUT_SCHEMA,
37
+ route="PUT /comments/{comment_id}/pin",
38
+ example_response=EXAMPLE_RESPONSE,
39
+ )
40
+ async def pin_comment(ctx: ToolContext, args: dict[str, Any]) -> Any:
41
+
42
+ pub = await publication(ctx)
43
+ cid = comment_id(args["comment_id"])
44
+ pinned = not args.get("unpin")
45
+ await call(ctx, "PATCH", f"{pub['base']}/api/v1/comment/{cid}/pin", body={"pinned": pinned})
46
+ return {"comment_id": str(cid), "pinned": pinned}
@@ -0,0 +1,83 @@
1
+ """Publish 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, draft, post_id, post_url, publication
10
+ from substack_api_mcp.substack.markdown import to_note_doc
11
+ from substack_api_mcp.substack.publishing import future_time
12
+
13
+ if TYPE_CHECKING:
14
+ from substack_api_mcp.core.context import ToolContext
15
+
16
+ INPUT_SCHEMA: dict[str, Any] = {
17
+ "type": "object",
18
+ "properties": {
19
+ "body": {"type": "string", "description": "Note text, up to 1,000 characters."},
20
+ "link_post_id": {"type": "string", "description": "Attach one of your published posts as a link card."},
21
+ "publish_at": {
22
+ "type": "string",
23
+ "format": "date-time",
24
+ "description": "ISO 8601 with time zone. Omit to post now.",
25
+ },
26
+ },
27
+ "required": ["body"],
28
+ "additionalProperties": False,
29
+ }
30
+
31
+ EXAMPLE_RESPONSE: dict[str, Any] = {
32
+ "note_id": "5521098",
33
+ "status": "published",
34
+ "published_at": "2026-10-11T10:05:00Z",
35
+ "url": "https://substack.com/@yourname/note/c-5521098",
36
+ }
37
+
38
+
39
+ @tool(
40
+ name="publish_note",
41
+ title="Publish Note",
42
+ access="write",
43
+ confirm=True,
44
+ description=(
45
+ "Short post in Substack Notes, now or at a set time, optionally with a link card to one of your posts."
46
+ ),
47
+ details=(
48
+ "Posts a Note under your name. Without publish_at it goes out now; with it, the Note waits in your "
49
+ "scheduled Notes, status is scheduled and published_at is that time. Text is 1 to 1,000 characters. "
50
+ "link_post_id must be a published post. By default you confirm each call; you can switch that off "
51
+ "for this tool."
52
+ ),
53
+ input_schema=INPUT_SCHEMA,
54
+ route="POST /notes",
55
+ example_response=EXAMPLE_RESPONSE,
56
+ )
57
+ async def publish_note(ctx: ToolContext, args: dict[str, Any]) -> Any:
58
+
59
+ text = args["body"].strip()
60
+ if not text or len(text) > 1000:
61
+ raise ToolError("body must be 1 to 1,000 characters.", ErrorCode.INVALID_INPUT)
62
+ pub = await publication(ctx)
63
+ doc = to_note_doc(text)
64
+ note: dict[str, Any] = {"bodyJson": doc, "tabId": "for-you", "surface": "feed", "replyMinimumRole": "everyone"}
65
+ if args.get("link_post_id"):
66
+ url = post_url(pub, await draft(ctx, pub, post_id(args["link_post_id"])))
67
+ if not url:
68
+ raise ToolError("link_post_id must be a published post.", ErrorCode.INVALID_INPUT)
69
+ card = await call(ctx, "POST", f"{WEB}/api/v1/comment/attachment", body={"url": url, "type": "link"})
70
+ note["attachmentIds"] = [card["id"]]
71
+ if args.get("publish_at"):
72
+ # A scheduled Note is a draft Note with its time: Notes > Scheduled.
73
+ note["trigger_at"] = future_time(args["publish_at"]).strftime("%Y-%m-%dT%H:%M:%S.000Z")
74
+ saved = await call(ctx, "POST", f"{WEB}/api/v1/comment/draft", body=note)
75
+ else:
76
+ saved = await call(ctx, "POST", f"{WEB}/api/v1/comment/feed", body=note)
77
+ scheduled = bool(args.get("publish_at"))
78
+ return {
79
+ "note_id": str(saved["id"]),
80
+ "status": "scheduled" if scheduled else "published",
81
+ "published_at": args["publish_at"] if scheduled else saved.get("date"),
82
+ "url": f"{WEB}/@{pub['handle']}/note/c-{saved['id']}",
83
+ }
@@ -0,0 +1,72 @@
1
+ """Publish 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, 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
+ "send_email": {"type": "boolean", "description": "Email it to subscribers. Default: true."},
19
+ "section_id": {
20
+ "type": "string",
21
+ "description": "Section ID from list_sections, or main. Default: the draft's section, else main.",
22
+ },
23
+ },
24
+ "required": ["post_id"],
25
+ "additionalProperties": False,
26
+ }
27
+
28
+ EXAMPLE_RESPONSE: dict[str, Any] = {
29
+ "post_id": "148420031",
30
+ "status": "published",
31
+ "published_at": "2026-10-11T10:02:00Z",
32
+ "emailed": True,
33
+ "url": "https://yourname.substack.com/p/notes-on-pricing",
34
+ }
35
+
36
+
37
+ @tool(
38
+ name="publish_post",
39
+ title="Publish post",
40
+ access="write",
41
+ confirm=True,
42
+ description="Publish a draft now.",
43
+ details=(
44
+ "Publishes a draft immediately, on the web and, unless send_email is false, by email, to the audience set"
45
+ " on the draft. Substack's own pre-publish check runs first; a missing title or an empty body stops the "
46
+ "call. A sent email cannot be recalled. On a publication with sections the post goes to section_id, else "
47
+ "to the section already set on the draft, else to the main publication. By default you confirm each call;"
48
+ " you can switch that off for this tool."
49
+ ),
50
+ input_schema=INPUT_SCHEMA,
51
+ route="POST /posts/{post_id}/publish",
52
+ example_response=EXAMPLE_RESPONSE,
53
+ )
54
+ async def publish_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
55
+
56
+ pub = await publication(ctx)
57
+ pid = post_id(args["post_id"])
58
+ post = await draft(ctx, pub, pid)
59
+ require_draft(post)
60
+ send_email = args.get("send_email", True)
61
+ await set_options(ctx, pub, post, send_email, args.get("section_id"))
62
+ await check_ready(ctx, pub, pid, None)
63
+ body = {"send": send_email, "share_automatically": False}
64
+ published = await call(ctx, "POST", f"{pub['base']}/api/v1/drafts/{pid}/publish", body=body)
65
+ slug = published.get("slug")
66
+ return {
67
+ "post_id": str(pid),
68
+ "status": "published",
69
+ "published_at": published.get("post_date"),
70
+ "emailed": bool(published.get("should_send_email", send_email)),
71
+ "url": f"{pub['base']}/p/{slug}" if slug else None,
72
+ }
@@ -0,0 +1,66 @@
1
+ """Recommend publication."""
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
+ "publication_id": {"type": "string", "description": "Publication ID, e.g. from search_substack posts."},
18
+ "blurb": {"type": "string", "description": "Why you recommend it, shown to your readers (optional)."},
19
+ "remove": {"type": "boolean", "description": "Stop recommending it instead. Default: false."},
20
+ },
21
+ "required": ["publication_id"],
22
+ "additionalProperties": False,
23
+ }
24
+
25
+ EXAMPLE_RESPONSE: dict[str, Any] = {"publication_id": "5262902", "name": "Brain Health Decoded", "recommended": True}
26
+
27
+
28
+ @tool(
29
+ name="recommend_publication",
30
+ title="Recommend publication",
31
+ access="write",
32
+ confirm=True,
33
+ description="Recommend another publication to your readers, or stop recommending it.",
34
+ details=(
35
+ "Adds a publication to your Recommendations, as Growth > Recommendations does: Substack suggests it "
36
+ "to people who subscribe to you and notifies its author. blurb is the short reason readers see. With "
37
+ "remove it takes the recommendation back. By default you confirm each call; you can switch that off "
38
+ "for this tool."
39
+ ),
40
+ input_schema=INPUT_SCHEMA,
41
+ route="PUT /recommendations/{publication_id}",
42
+ example_response=EXAMPLE_RESPONSE,
43
+ )
44
+ async def recommend_publication(ctx: ToolContext, args: dict[str, Any]) -> Any:
45
+
46
+ target = str(args["publication_id"]).strip()
47
+ if not target.isdigit():
48
+ raise ToolError("publication_id must be a numeric publication ID.", ErrorCode.INVALID_INPUT)
49
+ pub = await publication(ctx)
50
+ if int(target) == pub["id"]:
51
+ raise ToolError("A publication cannot recommend itself.", ErrorCode.INVALID_INPUT)
52
+ body: dict[str, Any] = {
53
+ "recommended_publication_id": int(target),
54
+ "recommending_publication_id": pub["id"],
55
+ "source": "publisher_dashboard",
56
+ }
57
+ if args.get("remove"):
58
+ await call(ctx, "DELETE", f"{pub['base']}/api/v1/recommendations", body=body)
59
+ return {"publication_id": target, "name": None, "recommended": False}
60
+ blurb = str(args.get("blurb") or "").strip()
61
+ if blurb and len(blurb) < 2:
62
+ raise ToolError("blurb must be at least 2 characters.", ErrorCode.INVALID_INPUT)
63
+ body |= {"list_ids": [], "list_assignment": "add", **({"description": blurb} if blurb else {})}
64
+ saved = await call(ctx, "PUT", f"{pub['base']}/api/v1/recommendations", body=body)
65
+ name = (saved.get("recommendedPublication") or {}).get("name")
66
+ return {"publication_id": target, "name": name, "recommended": True}
@@ -0,0 +1,45 @@
1
+ """Remove comment."""
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, comment_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": {"comment_id": {"type": "string", "description": "Comment ID from list_comments."}},
16
+ "required": ["comment_id"],
17
+ "additionalProperties": False,
18
+ }
19
+
20
+ EXAMPLE_RESPONSE: dict[str, Any] = {"comment_id": "364149469", "status": "moderator_removed"}
21
+
22
+
23
+ @tool(
24
+ name="remove_comment",
25
+ title="Remove comment",
26
+ access="write",
27
+ confirm=True,
28
+ description="Remove someone's comment from your post as the publication's moderator.",
29
+ details=(
30
+ "Hides a comment on a post of your publication, as Remove in the comment menu does: readers see "
31
+ "that a moderator removed it, the text is gone, replies under it stay. Use delete_comment for your "
32
+ "own comments. A comment on someone else's post returns an error. By default you confirm each call; "
33
+ "you can switch that off for this tool."
34
+ ),
35
+ input_schema=INPUT_SCHEMA,
36
+ route="POST /comments/{comment_id}/remove",
37
+ example_response=EXAMPLE_RESPONSE,
38
+ )
39
+ async def remove_comment(ctx: ToolContext, args: dict[str, Any]) -> Any:
40
+
41
+ pub = await publication(ctx)
42
+ cid = comment_id(args["comment_id"])
43
+ body = {"status": "moderator_removed"}
44
+ answer = await call(ctx, "PATCH", f"{pub['base']}/api/v1/comment/{cid}/status", body=body)
45
+ return {"comment_id": str(cid), "status": answer.get("status") or "moderator_removed"}
@@ -0,0 +1,57 @@
1
+ """Remove subscriber."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
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 call, publication
11
+ from substack_api_mcp.substack.subscribers import find_subscriber
12
+
13
+ if TYPE_CHECKING:
14
+ from substack_api_mcp.core.context import ToolContext
15
+
16
+ INPUT_SCHEMA: dict[str, Any] = {
17
+ "type": "object",
18
+ "properties": {"email": {"type": "string", "description": "Subscriber's email address."}},
19
+ "required": ["email"],
20
+ "additionalProperties": False,
21
+ }
22
+
23
+ EXAMPLE_RESPONSE: dict[str, Any] = {"email": "reader@example.com", "removed": True}
24
+
25
+
26
+ @tool(
27
+ name="remove_subscriber",
28
+ title="Remove subscriber",
29
+ access="write",
30
+ confirm=True,
31
+ description="Remove one email from your subscriber list.",
32
+ details=(
33
+ "Removes a subscriber from your list, as Remove in the subscriber's menu does: they get no more emails "
34
+ "from you and no notice. A paid subscription is cancelled and Substack refunds the unused part. An "
35
+ "email not on your list returns an error; add_subscriber takes about a minute before the address is "
36
+ "listed. By default you confirm each call; you can switch that off for this tool."
37
+ ),
38
+ input_schema=INPUT_SCHEMA,
39
+ route="DELETE /subscribers/{email}",
40
+ example_response=EXAMPLE_RESPONSE,
41
+ )
42
+ async def remove_subscriber(ctx: ToolContext, args: dict[str, Any]) -> Any:
43
+
44
+ email = str(args["email"]).strip()
45
+ if not re.fullmatch(r"[^@\s/]+@[^@\s/]+\.[^@\s/]+", email):
46
+ raise ToolError("email must be one email address.", ErrorCode.INVALID_INPUT)
47
+ pub = await publication(ctx)
48
+ row = await find_subscriber(ctx, pub, email)
49
+ if not row:
50
+ # Substack answers 200 to a removal of an address it does not have, so the table decides.
51
+ raise ToolError(
52
+ "This email is not on your subscriber list. One added in the last few minutes may not be listed "
53
+ "yet; try again shortly.",
54
+ ErrorCode.TARGET_ERROR,
55
+ )
56
+ await call(ctx, "DELETE", f"{pub['base']}/api/v1/subscriber-stats/remove/{row['user_email_address']}")
57
+ return {"email": row["user_email_address"], "removed": True}