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,239 @@
1
+ """Markdown to and from the ProseMirror documents Substack's editor stores (`draft_body`, `body`, Note `bodyJson`).
2
+
3
+ Covers what agents write: headings, paragraphs, bullet and numbered lists, quotes, code blocks, rules,
4
+ images by URL, and inline bold, italic, strike, code and links. Node and mark names are the editor's,
5
+ as recorded from drafts typed in it (17.10.2026). Anything else Substack stores (buttons, embeds,
6
+ footnotes) comes back as its plain text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ _INLINE = re.compile(
15
+ r"(?P<code>`[^`]+`)"
16
+ r"|(?P<image>!\[(?P<alt>[^\]]*)\]\((?P<src>[^)\s]+)\))"
17
+ r"|(?P<link>\[(?P<label>[^\]]+)\]\((?P<href>[^)\s]+)\))"
18
+ r"|(?P<strong>\*\*(?P<strong_text>.+?)\*\*|__(?P<strong_alt>.+?)__)"
19
+ r"|(?P<strike>~~(?P<strike_text>.+?)~~)"
20
+ r"|(?P<em>\*(?P<em_text>[^*\s][^*]*?)\*|_(?P<em_alt>[^_\s][^_]*?)_)"
21
+ )
22
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
23
+ _BULLET = re.compile(r"^\s*[-*+]\s+(.*)$")
24
+ _NUMBERED = re.compile(r"^\s*\d+[.)]\s+(.*)$")
25
+ _IMAGE_LINE = re.compile(r"^!\[([^\]]*)\]\(([^)\s]+)\)$")
26
+ _RULE = re.compile(r"^\s*([-*_])(\s*\1){2,}\s*$")
27
+
28
+
29
+ def _text(text: str, marks: list[dict[str, Any]]) -> dict[str, Any]:
30
+ node: dict[str, Any] = {"type": "text", "text": text}
31
+ if marks:
32
+ node["marks"] = marks
33
+ return node
34
+
35
+
36
+ def inline(text: str, marks: list[dict[str, Any]] | None = None) -> list[dict[str, Any]]:
37
+ """Inline Markdown to text nodes with marks."""
38
+ marks = marks or []
39
+ nodes: list[dict[str, Any]] = []
40
+ position = 0
41
+ for match in _INLINE.finditer(text):
42
+ if match.start() > position:
43
+ nodes.append(_text(text[position : match.start()], marks))
44
+ if match["code"]:
45
+ nodes.append(_text(match["code"][1:-1], [*marks, {"type": "code"}]))
46
+ elif match["image"]:
47
+ nodes.extend(
48
+ inline(match["alt"] or match["src"], [*marks, {"type": "link", "attrs": {"href": match["src"]}}])
49
+ )
50
+ elif match["link"]:
51
+ nodes.extend(inline(match["label"], [*marks, {"type": "link", "attrs": {"href": match["href"]}}]))
52
+ elif match["strong"]:
53
+ nodes.extend(inline(match["strong_text"] or match["strong_alt"], [*marks, {"type": "strong"}]))
54
+ elif match["strike"]:
55
+ nodes.extend(inline(match["strike_text"], [*marks, {"type": "strikethrough"}]))
56
+ else:
57
+ nodes.extend(inline(match["em_text"] or match["em_alt"], [*marks, {"type": "em"}]))
58
+ position = match.end()
59
+ if position < len(text):
60
+ nodes.append(_text(text[position:], marks))
61
+ return [node for node in nodes if node.get("text")]
62
+
63
+
64
+ def _paragraph(text: str) -> dict[str, Any]:
65
+ content = inline(text)
66
+ return {"type": "paragraph", "content": content} if content else {"type": "paragraph"}
67
+
68
+
69
+ def _image(alt: str, src: str) -> dict[str, Any]:
70
+ return {"type": "captionedImage", "content": [{"type": "image2", "attrs": {"src": src, "alt": alt or None}}]}
71
+
72
+
73
+ def _list(kind: str, items: list[str]) -> dict[str, Any]:
74
+ node: dict[str, Any] = {
75
+ "type": kind,
76
+ "content": [{"type": "listItem", "content": [_paragraph(item)]} for item in items],
77
+ }
78
+ if kind == "orderedList":
79
+ node["attrs"] = {"start": 1, "order": 1}
80
+ return node
81
+
82
+
83
+ def to_doc(markdown: str) -> dict[str, Any]:
84
+ """A Markdown text as a ProseMirror document."""
85
+ lines = markdown.replace("\r\n", "\n").split("\n")
86
+ blocks: list[dict[str, Any]] = []
87
+ index = 0
88
+ while index < len(lines):
89
+ line = lines[index]
90
+ stripped = line.strip()
91
+ if not stripped:
92
+ index += 1
93
+ continue
94
+ if stripped.startswith("```"):
95
+ code: list[str] = []
96
+ index += 1
97
+ while index < len(lines) and not lines[index].strip().startswith("```"):
98
+ code.append(lines[index])
99
+ index += 1
100
+ index += 1
101
+ text = "\n".join(code)
102
+ blocks.append({"type": "code_block", "content": [_text(text, [])]} if text else {"type": "code_block"})
103
+ continue
104
+ if _RULE.match(stripped):
105
+ blocks.append({"type": "horizontal_rule"})
106
+ index += 1
107
+ continue
108
+ if heading := _HEADING.match(stripped):
109
+ content = inline(heading[2].strip())
110
+ blocks.append({"type": "heading", "attrs": {"level": len(heading[1])}, "content": content})
111
+ index += 1
112
+ continue
113
+ if image := _IMAGE_LINE.match(stripped):
114
+ blocks.append(_image(image[1], image[2]))
115
+ index += 1
116
+ continue
117
+ for pattern, kind in ((_BULLET, "bulletList"), (_NUMBERED, "orderedList")):
118
+ if pattern.match(line):
119
+ items = []
120
+ while index < len(lines) and (item := pattern.match(lines[index])):
121
+ items.append(item[1].strip())
122
+ index += 1
123
+ blocks.append(_list(kind, items))
124
+ break
125
+ else:
126
+ if stripped.startswith(">"):
127
+ quoted = []
128
+ while index < len(lines) and lines[index].strip().startswith(">"):
129
+ quoted.append(lines[index].strip()[1:].lstrip())
130
+ index += 1
131
+ inner = to_doc("\n".join(quoted))["content"]
132
+ blocks.append({"type": "blockquote", "content": inner or [{"type": "paragraph"}]})
133
+ continue
134
+ paragraph = [stripped]
135
+ index += 1
136
+ while index < len(lines) and lines[index].strip() and not _starts_block(lines[index]):
137
+ paragraph.append(lines[index].strip())
138
+ index += 1
139
+ blocks.append(_paragraph(" ".join(paragraph)))
140
+ return {"type": "doc", "content": blocks or [{"type": "paragraph"}]}
141
+
142
+
143
+ def _starts_block(line: str) -> bool:
144
+ stripped = line.strip()
145
+ return bool(
146
+ stripped.startswith(("```", ">", "#"))
147
+ or _BULLET.match(line)
148
+ or _NUMBERED.match(line)
149
+ or _IMAGE_LINE.match(stripped)
150
+ or _RULE.match(stripped)
151
+ )
152
+
153
+
154
+ def _marked(node: dict[str, Any]) -> str:
155
+ text = node.get("text") or ""
156
+ for mark in node.get("marks") or []:
157
+ match mark.get("type"):
158
+ case "strong":
159
+ text = f"**{text}**"
160
+ case "em":
161
+ text = f"*{text}*"
162
+ case "strikethrough":
163
+ text = f"~~{text}~~"
164
+ case "code":
165
+ text = f"`{text}`"
166
+ case "link":
167
+ text = f"[{text}]({(mark.get('attrs') or {}).get('href', '')})"
168
+ return text
169
+
170
+
171
+ def _inline_md(nodes: list[dict[str, Any]]) -> str:
172
+ parts = []
173
+ for node in nodes or []:
174
+ if node.get("type") == "text":
175
+ parts.append(_marked(node))
176
+ elif node.get("type") == "hard_break":
177
+ parts.append("\n")
178
+ else:
179
+ parts.append(_inline_md(node.get("content") or []))
180
+ return "".join(parts)
181
+
182
+
183
+ def _block_md(node: dict[str, Any]) -> str:
184
+ content = node.get("content") or []
185
+ match node.get("type"):
186
+ case "paragraph":
187
+ return _inline_md(content)
188
+ case "heading":
189
+ return "#" * int((node.get("attrs") or {}).get("level") or 1) + " " + _inline_md(content)
190
+ case "bulletList" | "bullet_list":
191
+ return "\n".join(f"- {_block_md({'type': 'doc', 'content': item.get('content')})}" for item in content)
192
+ case "orderedList" | "ordered_list":
193
+ return "\n".join(
194
+ f"{n}. {_block_md({'type': 'doc', 'content': item.get('content')})}"
195
+ for n, item in enumerate(content, 1)
196
+ )
197
+ case "blockquote" | "pullquote":
198
+ return "\n".join(f"> {line}" for line in _block_md({"type": "doc", "content": content}).split("\n"))
199
+ case "code_block":
200
+ return "```\n" + _inline_md(content) + "\n```"
201
+ case "horizontal_rule":
202
+ return "---"
203
+ case "captionedImage" | "image2" | "image":
204
+ image = (
205
+ node
206
+ if node.get("type") != "captionedImage"
207
+ else next((child for child in content if child.get("type") in ("image2", "image")), {})
208
+ )
209
+ attrs = image.get("attrs") or {}
210
+ return f"![{attrs.get('alt') or ''}]({attrs.get('src') or ''})" if attrs.get("src") else ""
211
+ case _:
212
+ blocks = [_block_md(child) for child in content if child.get("type") != "text"]
213
+ return "\n\n".join(block for block in blocks if block) or _inline_md(content)
214
+
215
+
216
+ def to_markdown(doc: dict[str, Any]) -> str:
217
+ """A ProseMirror document as Markdown."""
218
+ blocks = [_block_md(node) for node in doc.get("content") or []]
219
+ return "\n\n".join(block for block in blocks if block).strip()
220
+
221
+
222
+ # Notes have their own editor schema (recorded 17.10.2026): paragraphs only, marks `bold`, `italic` and `link`.
223
+ _NOTE_MARKS = {"strong": "bold", "em": "italic", "link": "link"}
224
+
225
+
226
+ def to_note_doc(markdown: str) -> dict[str, Any]:
227
+ """A Note's text as its ProseMirror document: one paragraph per block, unsupported marks dropped."""
228
+ paragraphs = []
229
+ for block in re.split(r"\n\s*\n", markdown.replace("\r\n", "\n").strip()):
230
+ nodes = []
231
+ for node in inline(" ".join(line.strip() for line in block.split("\n"))):
232
+ marks = [
233
+ {**mark, "type": _NOTE_MARKS[mark["type"]]}
234
+ for mark in node.get("marks") or []
235
+ if mark["type"] in _NOTE_MARKS
236
+ ]
237
+ nodes.append(_text(node["text"], marks))
238
+ paragraphs.append({"type": "paragraph", "content": nodes} if nodes else {"type": "paragraph"})
239
+ return {"type": "doc", "attrs": {"schemaVersion": "v1", "title": None}, "content": paragraphs}
@@ -0,0 +1,70 @@
1
+ """Steps the editor's Publish dialog takes before a post goes out, shared by publish_post and schedule_post."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
9
+ from substack_api_mcp.substack.client import byline, call
10
+
11
+ if TYPE_CHECKING:
12
+ from substack_api_mcp.core.context import ToolContext
13
+
14
+
15
+ def future_time(value: str) -> datetime:
16
+ """An ISO 8601 time with a zone, later than now."""
17
+ try:
18
+ when = datetime.fromisoformat(value.replace("Z", "+00:00"))
19
+ except ValueError as exc:
20
+ raise ToolError("Time must be ISO 8601, e.g. 2026-10-20T08:00:00-04:00.", ErrorCode.INVALID_INPUT) from exc
21
+ if when.tzinfo is None:
22
+ raise ToolError("Time must include a time zone, e.g. -04:00 or Z.", ErrorCode.INVALID_INPUT)
23
+ if when <= datetime.now(UTC):
24
+ raise ToolError("Time must be in the future.", ErrorCode.INVALID_INPUT)
25
+ return when.astimezone(UTC)
26
+
27
+
28
+ async def set_options(
29
+ ctx: ToolContext, pub: dict[str, Any], post: dict[str, Any], send_email: bool, section_id: Any = None
30
+ ) -> None:
31
+ """The dialog's "Send via email" box and section choice, saved on the draft before it goes out.
32
+
33
+ A publication with sections refuses to publish until the draft names one ("Please choose a section");
34
+ the editor saves section_chosen with draft_section_id, null for the main publication.
35
+ """
36
+ section = _section(section_id)
37
+ email_same = bool(post.get("should_send_email", True)) == send_email
38
+ section_same = bool(post.get("section_chosen")) and (section_id is None or post.get("draft_section_id") == section)
39
+ if email_same and section_same:
40
+ return
41
+ body = {
42
+ "draft_title": post.get("draft_title") or "",
43
+ "draft_subtitle": post.get("draft_subtitle") or "",
44
+ "draft_body": post.get("draft_body"),
45
+ "draft_bylines": byline(pub),
46
+ "audience": post.get("audience") or "everyone",
47
+ "should_send_email": send_email,
48
+ "section_chosen": True,
49
+ "draft_section_id": section if section_id is not None else post.get("draft_section_id"),
50
+ "last_updated_at": post.get("draft_updated_at"),
51
+ }
52
+ await call(ctx, "PUT", f"{pub['base']}/api/v1/drafts/{post['id']}", body=body)
53
+
54
+
55
+ def _section(value: Any) -> int | None:
56
+ if value is None or str(value).strip() in ("", "main"):
57
+ return None
58
+ text = str(value).strip()
59
+ if not text.isdigit():
60
+ raise ToolError("section_id must be the ID from list_sections, or main.", ErrorCode.INVALID_INPUT)
61
+ return int(text)
62
+
63
+
64
+ async def check_ready(ctx: ToolContext, pub: dict[str, Any], pid: int, publish_date: str | None) -> None:
65
+ """Substack's own pre-publish check; its errors (no title, empty body) stop the call, suggestions do not."""
66
+ params = {"publish_date": publish_date} if publish_date else None
67
+ answer = await call(ctx, "GET", f"{pub['base']}/api/v1/drafts/{pid}/prepublish", params=params)
68
+ if errors := answer.get("errors") or []:
69
+ names = ", ".join(str(error.get("identifier") or error.get("msg") or error) for error in errors)
70
+ raise ToolError(f"Substack will not publish this draft yet: {names}.", ErrorCode.INVALID_INPUT)
@@ -0,0 +1,94 @@
1
+ """The dashboard's subscriber table (Audience > Subscribers) and its CSV export.
2
+
3
+ The table is `POST /api/v1/subscriber-stats` with the dashboard's filters. Substack fills it from its own
4
+ index in the background, so a subscriber added a minute ago may not be listed yet.
5
+ The export builds a subscriber set from the same order, asks for a CSV of it and polls until Substack
6
+ hands out the file, as the dashboard's Export button does (`exportSubscriberSet` in its bundle, 17.10.2026).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from datetime import UTC, datetime, timedelta
13
+ from typing import TYPE_CHECKING, Any
14
+ from urllib.parse import parse_qs, urlsplit
15
+
16
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
17
+ from substack_api_mcp.substack.client import call
18
+
19
+ if TYPE_CHECKING:
20
+ from substack_api_mcp.core.context import ToolContext
21
+
22
+ _ORDER = {"order_by_desc_nulls_last": "subscription_created_at"}
23
+ _COLUMNS = ["user_email_address", "user_name", "subscription_type", "activity_rating", "subscription_created_at"]
24
+ # Seconds between polls for the export file, as the dashboard waits: 1, 5, 10, then 30.
25
+ _POLL_S = (1, 4, 5, 10, 10, 15)
26
+
27
+
28
+ def plan_of(row: dict[str, Any]) -> str:
29
+ """free, paid, comp or founding, as the subscriber table labels the row."""
30
+ if row.get("is_comp"):
31
+ return "comp"
32
+ if row.get("is_founding"):
33
+ return "founding"
34
+ kind = str(row.get("subscription_type") or "").lower()
35
+ return "free" if not kind or "free" in kind else "paid"
36
+
37
+
38
+ async def subscriber_page(
39
+ ctx: ToolContext, pub: dict[str, Any], offset: int, size: int, since: str | None
40
+ ) -> list[dict[str, Any]]:
41
+ """One page of the table, newest first; `since` stops at subscribers who joined before that date."""
42
+ body = {"filters": _ORDER, "limit": size, "offset": offset, "includeTags": False}
43
+ answer = await call(ctx, "POST", f"{pub['base']}/api/v1/subscriber-stats", body=body)
44
+ rows = answer.get("subscribers") or []
45
+ if since:
46
+ rows = [row for row in rows if (row.get("subscription_created_at") or "")[:10] >= since[:10]]
47
+ return rows
48
+
49
+
50
+ async def export(ctx: ToolContext, pub: dict[str, Any]) -> dict[str, Any]:
51
+ base = f"{pub['base']}/api/v1"
52
+ subscriber_set = await call(ctx, "POST", f"{base}/subscriber_set", body={"query": _ORDER})
53
+ if not subscriber_set.get("id"):
54
+ raise ToolError("Substack did not start the export.", ErrorCode.TARGET_ERROR)
55
+ body = {"subscriberSetId": subscriber_set["id"], "columns": _COLUMNS}
56
+ job = await call(ctx, "POST", f"{base}/subscriber_set/export", body=body)
57
+ for wait_s in _POLL_S:
58
+ await asyncio.sleep(wait_s)
59
+ response = await ctx.call("GET", f"{base}/subscriber_set/export/{job.get('export_id')}")
60
+ path = response.json().get("url") if response.status == 200 else None
61
+ if path:
62
+ return await _file(ctx, pub, path)
63
+ raise ToolError("Substack is still building the export; try again in a few minutes.", ErrorCode.TIMEOUT)
64
+
65
+
66
+ async def _file(ctx: ToolContext, pub: dict[str, Any], path: str) -> dict[str, Any]:
67
+ """The export's `/file` path redirects to a presigned S3 link: that link is the answer, valid without cookies."""
68
+ response = await call_file(ctx, f"{pub['base']}{path}")
69
+ query = parse_qs(urlsplit(response.url).query)
70
+ signed = datetime.strptime(query["X-Amz-Date"][0], "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
71
+ lines = [line for line in response.text.splitlines() if line.strip()]
72
+ return {
73
+ "download_url": response.url,
74
+ "rows": max(len(lines) - 1, 0),
75
+ "expires_at": (signed + timedelta(seconds=int(query["X-Amz-Expires"][0]))).isoformat(),
76
+ }
77
+
78
+
79
+ async def call_file(ctx: ToolContext, url: str) -> Any:
80
+ response = await ctx.call("GET", url, headers={"accept": "text/csv"})
81
+ if response.status != 200 or "X-Amz-Expires" not in response.url:
82
+ raise ToolError("Substack did not hand out the export file.", ErrorCode.TARGET_ERROR)
83
+ return response
84
+
85
+
86
+ async def find_subscriber(ctx: ToolContext, pub: dict[str, Any], email: str) -> dict[str, Any] | None:
87
+ """The table row of one email: the dashboard's search box, then an exact match on the address."""
88
+ body = {"filters": {**_ORDER, "search": email}, "limit": 10, "offset": 0, "includeTags": False}
89
+ answer = await call(ctx, "POST", f"{pub['base']}/api/v1/subscriber-stats", body=body)
90
+ wanted = email.strip().lower()
91
+ return next(
92
+ (row for row in answer.get("subscribers") or [] if str(row.get("user_email_address")).lower() == wanted),
93
+ None,
94
+ )
File without changes
@@ -0,0 +1,60 @@
1
+ """Add subscriber."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
10
+ from substack_api_mcp.core.tools import tool
11
+ from substack_api_mcp.substack.client import call, publication
12
+
13
+ _EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
14
+
15
+ if TYPE_CHECKING:
16
+ from substack_api_mcp.core.context import ToolContext
17
+
18
+ INPUT_SCHEMA: dict[str, Any] = {
19
+ "type": "object",
20
+ "properties": {
21
+ "email": {"type": "string", "description": "Subscriber email."},
22
+ },
23
+ "required": ["email"],
24
+ "additionalProperties": False,
25
+ }
26
+
27
+ EXAMPLE_RESPONSE: dict[str, Any] = {
28
+ "email": "maya@example.com",
29
+ "plan": "free",
30
+ "status": "added",
31
+ "joined_at": "2026-10-11T10:09:00Z",
32
+ }
33
+
34
+
35
+ @tool(
36
+ name="add_subscriber",
37
+ title="Add subscriber",
38
+ access="write",
39
+ confirm=True,
40
+ description="Add one email to your list as a free subscriber.",
41
+ details=(
42
+ "Adds one email as a free subscriber, the same as Add subscribers in the dashboard, and Substack sends "
43
+ "its usual welcome email. Substack adds the email in the background, so list_subscribers may show it "
44
+ "a minute later. Add only people who agreed to hear from you. By default you confirm each call; you "
45
+ "can switch that off for this tool."
46
+ ),
47
+ input_schema=INPUT_SCHEMA,
48
+ route="POST /subscribers",
49
+ example_response=EXAMPLE_RESPONSE,
50
+ )
51
+ async def add_subscriber(ctx: ToolContext, args: dict[str, Any]) -> Any:
52
+
53
+ email = args["email"].strip().lower()
54
+ if not _EMAIL.match(email):
55
+ raise ToolError("email must be one email address.", ErrorCode.INVALID_INPUT)
56
+ pub = await publication(ctx)
57
+ # The dashboard's "Add subscribers by email" with the welcome email on; Substack adds the email in the background.
58
+ body = {"email": email, "subscription": False, "sendEmail": True}
59
+ await call(ctx, "POST", f"{pub['base']}/api/v1/subscriber/add", body=body)
60
+ return {"email": email, "plan": "free", "status": "added", "joined_at": datetime.now(UTC).isoformat()}
@@ -0,0 +1,54 @@
1
+ """Cancel scheduled 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, scheduled_at
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] = {
24
+ "post_id": "148213977",
25
+ "status": "draft",
26
+ "was_scheduled_for": "2026-10-20T12:00:00Z",
27
+ }
28
+
29
+
30
+ @tool(
31
+ name="cancel_scheduled_post",
32
+ title="Cancel scheduled post",
33
+ access="write",
34
+ confirm=False,
35
+ description="Stop a scheduled post from going out; it stays as a draft.",
36
+ details=(
37
+ "Removes the scheduled time from a post, so nothing is published or emailed then; the post stays a "
38
+ "draft with its content. Schedule it again with schedule_post. A post with no schedule returns an "
39
+ "error."
40
+ ),
41
+ input_schema=INPUT_SCHEMA,
42
+ route="DELETE /posts/{post_id}/schedule",
43
+ example_response=EXAMPLE_RESPONSE,
44
+ )
45
+ async def cancel_scheduled_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
46
+
47
+ pub = await publication(ctx)
48
+ pid = post_id(args["post_id"])
49
+ post = await draft(ctx, pub, pid)
50
+ when = scheduled_at(post)
51
+ if post.get("is_published") or not when:
52
+ raise ToolError("This post is not scheduled.", ErrorCode.INVALID_INPUT)
53
+ await call(ctx, "DELETE", f"{pub['base']}/api/v1/drafts/{pid}/scheduled_release")
54
+ return {"post_id": str(pid), "status": "draft", "was_scheduled_for": when}
@@ -0,0 +1,75 @@
1
+ """Create 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.tools import tool
9
+ from substack_api_mcp.substack.client import byline, call, publication
10
+ from substack_api_mcp.substack.markdown import to_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
+ "title": {"type": "string", "description": "Post title."},
19
+ "body_markdown": {"type": "string", "description": "Post body in Markdown. Images by public URL."},
20
+ "subtitle": {"type": "string", "description": "Shown under the title and in the email preview."},
21
+ "audience": {
22
+ "type": "string",
23
+ "enum": ["everyone", "only_paid", "founding"],
24
+ "description": "everyone, only_paid or founding. Default: everyone.",
25
+ },
26
+ },
27
+ "required": ["title", "body_markdown"],
28
+ "additionalProperties": False,
29
+ }
30
+
31
+ EXAMPLE_RESPONSE: dict[str, Any] = {
32
+ "post_id": "148420031",
33
+ "status": "draft",
34
+ "audience": "everyone",
35
+ "edit_url": "https://yourname.substack.com/publish/post/148420031",
36
+ }
37
+
38
+
39
+ @tool(
40
+ name="create_draft",
41
+ title="Create draft",
42
+ access="write",
43
+ confirm=False,
44
+ description="New draft from Markdown. Nothing is sent until you publish or schedule it.",
45
+ details=(
46
+ "Creates a draft in your publication and returns its post_id and editor link. Markdown covers "
47
+ "headings, paragraphs, bullet and numbered lists, quotes, code blocks, rules, images by public URL, "
48
+ "bold, italic, strikethrough, inline code and links; local image files cannot be used. Sending is a "
49
+ "separate step: schedule_post or publish_post. A draft reaches no one, so it runs without asking."
50
+ ),
51
+ input_schema=INPUT_SCHEMA,
52
+ route="POST /drafts",
53
+ example_response=EXAMPLE_RESPONSE,
54
+ )
55
+ async def create_draft(ctx: ToolContext, args: dict[str, Any]) -> Any:
56
+
57
+ pub = await publication(ctx)
58
+ audience = args.get("audience") or "everyone"
59
+ body = {
60
+ "draft_title": args["title"],
61
+ "draft_subtitle": args.get("subtitle") or "",
62
+ "draft_body": json.dumps(to_doc(args["body_markdown"])),
63
+ "draft_bylines": byline(pub),
64
+ "audience": audience,
65
+ "type": "newsletter",
66
+ "section_chosen": False,
67
+ "draft_section_id": None,
68
+ }
69
+ post = await call(ctx, "POST", f"{pub['base']}/api/v1/drafts", body=body)
70
+ return {
71
+ "post_id": str(post["id"]),
72
+ "status": "draft",
73
+ "audience": post.get("audience") or audience,
74
+ "edit_url": f"{pub['base']}/publish/post/{post['id']}",
75
+ }
@@ -0,0 +1,49 @@
1
+ """Create post tag."""
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": {"name": {"type": "string", "description": "Tag name, e.g. Habits."}},
17
+ "required": ["name"],
18
+ "additionalProperties": False,
19
+ }
20
+
21
+ EXAMPLE_RESPONSE: dict[str, Any] = {
22
+ "tag_id": "80ba205f-6de0-4024-860d-0df42720d32a",
23
+ "name": "Habits",
24
+ "slug": "habits",
25
+ }
26
+
27
+
28
+ @tool(
29
+ name="create_post_tag",
30
+ title="Create post tag",
31
+ access="write",
32
+ confirm=False,
33
+ description="New tag for grouping your posts.",
34
+ details=(
35
+ "Creates a post tag on your publication; Substack makes the slug from the name. A tag with no posts "
36
+ "shows nowhere. Put posts under it with tag_post."
37
+ ),
38
+ input_schema=INPUT_SCHEMA,
39
+ route="POST /post-tags",
40
+ example_response=EXAMPLE_RESPONSE,
41
+ )
42
+ async def create_post_tag(ctx: ToolContext, args: dict[str, Any]) -> Any:
43
+
44
+ name = str(args["name"]).strip()
45
+ if not name or len(name) > 100:
46
+ raise ToolError("name must be 1 to 100 characters.", ErrorCode.INVALID_INPUT)
47
+ pub = await publication(ctx)
48
+ tag = await call(ctx, "POST", f"{pub['base']}/api/v1/publication/post-tag", body={"name": name})
49
+ return {"tag_id": str(tag["id"]), "name": tag.get("name") or name, "slug": tag.get("slug") or ""}