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,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" 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 ""}
|