create_draft
Create a Substack draft from Markdown. Supply title and content, optionally subtitle and audience (everyone, paid, founding, or free). Returns the post ID and edit URL for further editing.
Instructions
Create a new Substack draft post from Markdown.
Args:
title: Post title (max 280 chars).
content_markdown: Body in Markdown. Supports headings, bold/italic, links,
bullet lists, blockquotes, code blocks, and images ( - local
paths are auto-uploaded to Substack CDN).
subtitle: Optional subtitle (max 280 chars).
audience: Who can read it: 'everyone' (default), 'only_paid', 'founding',
or 'only_free'.
Returns: Summary including post_id, title, edit_url.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| title | Yes | ||
| content_markdown | Yes | ||
| subtitle | No | ||
| audience | No | everyone |
Implementation Reference
- src/substack_mcp/server.py:26-27 (registration)MCP tool registration via @mcp.tool() decorator on create_draft function
@mcp.tool() def create_draft( - src/substack_mcp/server.py:27-60 (handler)MCP tool handler: validates inputs (title length, content non-empty), then delegates to client.create_draft()
def create_draft( title: str, content_markdown: str, subtitle: str = "", audience: str = "everyone", ) -> dict: """Create a new Substack draft post from Markdown. Args: title: Post title (max 280 chars). content_markdown: Body in Markdown. Supports headings, bold/italic, links, bullet lists, blockquotes, code blocks, and images ( - local paths are auto-uploaded to Substack CDN). subtitle: Optional subtitle (max 280 chars). audience: Who can read it: 'everyone' (default), 'only_paid', 'founding', or 'only_free'. Returns: Summary including post_id, title, edit_url. """ if not title or not isinstance(title, str): raise ValueError("title must be a non-empty string") if len(title) > 280: raise ValueError("title must be 280 characters or less") if subtitle and len(subtitle) > 280: raise ValueError("subtitle must be 280 characters or less") if not content_markdown: raise ValueError("content_markdown must be non-empty") return _get_client().create_draft( title=title, content_markdown=content_markdown, subtitle=subtitle, audience=audience, ) - src/substack_mcp/client.py:162-183 (handler)Client implementation: constructs a Post object from Markdown, normalizes ProseMirror body, and posts the draft via the Substack API
def create_draft( self, title: str, content_markdown: str, subtitle: str = "", audience: str = "everyone", ) -> dict: if audience not in VALID_AUDIENCES: raise ValueError( f"audience must be one of {sorted(VALID_AUDIENCES)}, got {audience!r}" ) post = Post( title=title, subtitle=subtitle, user_id=self.user_id, audience=audience, ) post.from_markdown(content_markdown, api=self._api) draft = post.get_draft() draft["draft_body"] = _normalize_prosemirror(draft["draft_body"]) result = self._api.post_draft(draft) return self._summarize_draft(result) - src/substack_mcp/client.py:18-18 (helper)VALID_AUDIENCES constant used for audience validation in create_draft
VALID_AUDIENCES = {"everyone", "only_paid", "founding", "only_free"} - src/substack_mcp/client.py:90-126 (helper)_normalize_prosemirror helper (and _fix_node) used to fix python-substack's ProseMirror JSON bugs before posting the draft
def _normalize_prosemirror(body_json: str) -> str: """Fix issues in python-substack's generated ProseMirror JSON. Bug 1: bullet/ordered list items emit text nodes shaped like {"content": "...", "marks": [...]} when ProseMirror requires {"type": "text", "text": "...", "marks": [...]} This walks the tree and rewrites any such nodes in place. """ body = json.loads(body_json) _fix_node(body) return json.dumps(body) def _fix_node(node: Any) -> None: if isinstance(node, list): for item in node: _fix_node(item) return if not isinstance(node, dict): return # Detect malformed text nodes: have "content" as string, no "type" if "type" not in node and isinstance(node.get("content"), str): text_value = node["content"] marks = node.get("marks") node.clear() node["type"] = "text" node["text"] = text_value if marks: node["marks"] = marks return # Recurse into children children = node.get("content") if children is not None: _fix_node(children)