Skip to content

Commit 4bc21df

Browse files
committed
Release 0.4.0: MCP read operations and safe publishing path
This release makes the optional MCP server significantly more useful for inspecting and preparing publications without steering agents toward immediate publishing.
1 parent 9372901 commit 4bc21df

10 files changed

Lines changed: 430 additions & 56 deletions

File tree

‎.gitignore‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ ENV/
110110
env.bak/
111111
venv.bak/
112112

113+
# Gemini CLI and Testing artifacts
114+
.gemini/
115+
tmp_pytest/
116+
113117
# Spyder project settings
114118
.spyderproject
115119
.spyproject

‎.pre-commit-config.yaml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ repos:
1414
hooks:
1515
- id: isort
1616
name: isort (python)
17+
args: ["--profile", "black"]

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
11
# Changelog
22

3+
## 0.4.0
4+
5+
### Added
6+
7+
- Seven new MCP tools for read operations and safe publishing: `get_status`, `list_publications`, `list_drafts`, `get_draft`, `schedule_draft`, `unschedule_draft`, and `publish_draft_checked`.
8+
- Pre-publish validation, interactive confirmation requirements, and no-email defaults for the new `publish_draft_checked` MCP tool.
9+
- Verified MCP client configuration examples in `docs/mcp.md`.
10+
- Documentation for Gemini CLI with Vertex AI (`docs/gemini-vertex-ai.md`).
11+
12+
### Improved
13+
14+
- Simplify MCP draft creation by routing through the existing `Api.create_draft_from_markdown` SDK helper, preserving response format.
15+
- Document the legacy `publish_draft` MCP tool as the compatibility interface.
16+
- Bump `cryptography` dependency from `48.0.1` to `50.0.0` (#68).
17+
318
## 0.3.0
419

520
### Added

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ See [MCP server](docs/mcp.md) for the tool list and safety notes.
227227
- [Low-level Python API](docs/low-level-api.md)
228228
- [YAML drafts](docs/yaml.md)
229229
- [MCP server](docs/mcp.md)
230+
- [Gemini CLI with Vertex AI](docs/gemini-vertex-ai.md)
230231
- [Safety and publishing behavior](docs/safety.md)
231232
- [Troubleshooting](docs/troubleshooting.md)
232233
- [Compatibility policy](docs/compatibility.md)

‎docs/mcp.md‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,59 @@ python -c "from substack_mcp.mcp_server import main; main()"
2121
The server uses the same `EMAIL`, `PASSWORD`, `PUBLICATION_URL`,
2222
`COOKIES_PATH`, and `COOKIES_STRING` environment variables as the SDK.
2323

24-
Available tools:
24+
## Available Tools
2525

26-
- `post_draft_from_markdown(...)`
27-
- `put_draft(draft_id, update_payload)`
28-
- `add_tags(draft_id, tags)`
29-
- `prepublish_draft(draft_id)`
30-
- `publish_draft(draft_id, send=True, share_automatically=False)`
26+
The server exposes tools for reading content, managing drafts, and publishing.
3127

32-
`post_draft_from_markdown` creates an unpublished draft unless `publish=True`
33-
is explicitly supplied. `publish_draft` publishes immediately and defaults to
34-
sending email. Review the draft ID and arguments before invoking it.
28+
**Read Operations (Safe to run automatically):**
29+
- `get_status()` - Verify authentication and get basic user/publication information.
30+
- `list_publications()` - List all publications you have access to.
31+
- `list_drafts(filter="draft", offset=0, limit=25)` - View recent drafts.
32+
- `get_draft(draft_id)` - Read the content and metadata of a specific draft.
3533

36-
The server currently exposes the compatibility tools listed above.
34+
**Draft Creation and Reversible Writes:**
35+
- `post_draft_from_markdown(...)` - Creates an unpublished draft from Markdown.
36+
- `put_draft(draft_id, update_payload)` - Update draft metadata (e.g. slug).
37+
- `add_tags(draft_id, tags)` - Attach tags to a draft.
38+
- `prepublish_draft(draft_id)` - Run Substack's pre-publication checks on a draft.
39+
- `schedule_draft(draft_id, at)` - Schedule a draft for publication at an ISO timestamp.
40+
- `unschedule_draft(draft_id)` - Remove a publication schedule from a draft.
41+
42+
**Publishing Operations (Use with caution):**
43+
- `publish_draft_checked(draft_id, confirm=False, send=False, share_automatically=False)` - **Recommended**. A safer publishing path that requires explicit confirmation (`confirm=True`), runs prepublish checks automatically, and defaults to *not* sending emails.
44+
- `publish_draft(draft_id, send=True, share_automatically=False)` - *Legacy compatibility interface*. Publishes immediately and defaults to sending email.
45+
46+
## Client Configuration Examples
47+
48+
### Claude Desktop
49+
50+
You can configure Claude Desktop to use `python-substack` as an MCP server by adding it to your `claude_desktop_config.json`:
51+
52+
```json
53+
{
54+
"mcpServers": {
55+
"substack": {
56+
"command": "substack-mcp",
57+
"env": {
58+
"EMAIL": "your-email@example.com",
59+
"PASSWORD": "your-password"
60+
}
61+
}
62+
}
63+
}
64+
```
65+
66+
If you prefer to use session cookies instead of a password, use the `COOKIES_STRING` variable:
67+
68+
```json
69+
{
70+
"mcpServers": {
71+
"substack": {
72+
"command": "substack-mcp",
73+
"env": {
74+
"COOKIES_STRING": "cookie1=value1; cookie2=value2"
75+
}
76+
}
77+
}
78+
}
79+
```

‎docs/releases/0.4.0.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
layout: default
3+
title: Release 0.4.0
4+
parent: Releases
5+
---
6+
7+
# Release 0.4.0
8+
9+
This release makes the optional MCP server significantly more useful for inspecting and preparing publications without steering agents toward immediate publishing.
10+
11+
### Added
12+
13+
- Seven new MCP tools for read operations and safe publishing: `get_status`, `list_publications`, `list_drafts`, `get_draft`, `schedule_draft`, `unschedule_draft`, and `publish_draft_checked`.
14+
- Pre-publish validation, interactive confirmation requirements, and no-email defaults for the new `publish_draft_checked` MCP tool.
15+
- Verified MCP client configuration examples in `docs/mcp.md`.
16+
- Documentation for Gemini CLI with Vertex AI (`docs/gemini-vertex-ai.md`).
17+
18+
### Improved
19+
20+
- Simplify MCP draft creation by routing through the existing `Api.create_draft_from_markdown` SDK helper, preserving response format.
21+
- Document the legacy `publish_draft` MCP tool as the compatibility interface.
22+
- Bump `cryptography` dependency from `48.0.1` to `50.0.0` (#68).

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-substack"
3-
version = "0.3.0"
3+
version = "0.4.0"
44
description = "Write and safely manage Substack drafts from Markdown with Python, CLI, and MCP."
55
authors = ["Paolo Mazza <mazzapaolo2019@gmail.com>"]
66
license = "MIT"

‎substack/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
__author__ = "Paolo Mazza"
44
__email__ = "mazzapaolo2019@gmail.com"
55
__license__ = "MIT License"
6-
__version__ = "0.3.0"
6+
__version__ = "0.4.0"
77
__url__ = "https://github.com/ma2za/python-substack"
88
__download_url__ = "https://pypi.python.org/pypi/python-substack"
99
__description__ = (

‎substack_mcp/mcp_server.py‎

Lines changed: 137 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -163,55 +163,24 @@ async def post_draft_from_markdown(
163163
This docstring example is meant to mirror the YAML-driven workflow and show how to decompose the same operations into explicit tool calls.
164164
"""
165165
client = get_api()
166-
user_id = client.get_user_id()
167166

168-
post = Post(
167+
return client.create_draft_from_markdown(
169168
title=title,
170-
subtitle=subtitle or "",
171-
user_id=user_id,
169+
markdown=markdown,
170+
subtitle=subtitle,
172171
audience=audience,
173172
write_comment_permissions=write_comment_permissions,
173+
search_engine_title=search_engine_title,
174+
search_engine_description=search_engine_description,
175+
slug=slug,
176+
draft_section_id=draft_section_id,
177+
tags=tags,
178+
prepublish=prepublish,
179+
publish=publish,
180+
send=send,
181+
share_automatically=share_automatically,
174182
)
175183

176-
post.from_markdown(markdown, api=client)
177-
178-
draft = client.post_draft(post.get_draft())
179-
180-
update_payload: Dict[str, Any] = {}
181-
if search_engine_title:
182-
update_payload["search_engine_title"] = search_engine_title
183-
if search_engine_description:
184-
update_payload["search_engine_description"] = search_engine_description
185-
if slug:
186-
update_payload["slug"] = slug
187-
if draft_section_id is not None:
188-
update_payload["draft_section_id"] = draft_section_id
189-
190-
if update_payload:
191-
draft = client.put_draft(draft.get("id"), **update_payload)
192-
193-
tags_list = _normalize_tags(tags)
194-
tags_result = None
195-
if tags_list:
196-
tags_result = client.add_tags_to_post(draft.get("id"), tags_list)
197-
198-
prepublish_result = None
199-
if prepublish:
200-
prepublish_result = client.prepublish_draft(draft.get("id"))
201-
202-
publish_result = None
203-
if publish:
204-
publish_result = client.publish_draft(
205-
draft.get("id"), send=send, share_automatically=share_automatically
206-
)
207-
208-
return {
209-
"draft": draft,
210-
"tags": tags_result,
211-
"prepublish": prepublish_result,
212-
"publish": publish_result,
213-
}
214-
215184

216185
@mcp.tool()
217186
async def put_draft(
@@ -269,7 +238,11 @@ async def publish_draft(
269238
send: bool = True,
270239
share_automatically: bool = False,
271240
) -> Dict[str, Any]:
272-
"""Publish a draft to live post state.
241+
"""Publish a draft to live post state. (Legacy compatibility interface).
242+
243+
This tool remains for backward compatibility. It is recommended to use
244+
`publish_draft_checked` instead, which provides a safer publishing path
245+
with explicit confirmation and prepublish validation.
273246
274247
Args:
275248
draft_id: target draft identifier.
@@ -285,6 +258,126 @@ async def publish_draft(
285258
)
286259

287260

261+
@mcp.tool()
262+
async def publish_draft_checked(
263+
draft_id: int,
264+
confirm: bool = False,
265+
send: bool = False,
266+
share_automatically: bool = False,
267+
) -> Dict[str, Any]:
268+
"""A safer publishing path that requires confirmation and runs prepublish checks.
269+
270+
Args:
271+
draft_id: target draft identifier.
272+
confirm: Must be True to proceed with publication.
273+
send: if False then do not send email to subscribers. Defaults to False.
274+
share_automatically: whether to auto-share.
275+
276+
Returns:
277+
Response from Substack `publish_draft`.
278+
"""
279+
if not confirm:
280+
raise ValueError("Publishing rejected: confirm parameter must be True.")
281+
282+
client = get_api()
283+
client.prepublish_draft(draft_id)
284+
return client.publish_draft(
285+
draft_id, send=send, share_automatically=share_automatically
286+
)
287+
288+
289+
@mcp.tool()
290+
async def get_status() -> Dict[str, Any]:
291+
"""Get the authentication status and basic user information.
292+
293+
Returns:
294+
A dictionary containing user profile and primary publication details.
295+
"""
296+
client = get_api()
297+
profile = client.get_user_profile()
298+
primary_pub = client.get_user_primary_publication()
299+
return {"profile": profile, "primary_publication": primary_pub}
300+
301+
302+
@mcp.tool()
303+
async def list_publications() -> List[Dict[str, Any]]:
304+
"""List all publications available to the authenticated user.
305+
306+
Returns:
307+
A list of publications.
308+
"""
309+
client = get_api()
310+
return client.get_user_publications()
311+
312+
313+
@mcp.tool()
314+
async def list_drafts(
315+
filter: str = "draft", offset: int = 0, limit: int = 25
316+
) -> List[Dict[str, Any]]:
317+
"""List drafts for the current publication.
318+
319+
Args:
320+
filter: Filter string, defaults to "draft".
321+
offset: Pagination offset.
322+
limit: Max number of drafts to return.
323+
324+
Returns:
325+
A list of drafts.
326+
"""
327+
client = get_api()
328+
return client.get_drafts(filter=filter, offset=offset, limit=limit)
329+
330+
331+
@mcp.tool()
332+
async def get_draft(draft_id: int) -> Dict[str, Any]:
333+
"""Get a specific draft by its ID.
334+
335+
Args:
336+
draft_id: The identifier of the draft.
337+
338+
Returns:
339+
The draft details.
340+
"""
341+
client = get_api()
342+
return client.get_draft(draft_id)
343+
344+
345+
@mcp.tool()
346+
async def schedule_draft(draft_id: int, at: str) -> Dict[str, Any]:
347+
"""Schedule a draft for release.
348+
349+
Args:
350+
draft_id: target draft identifier.
351+
at: ISO 8601 formatted datetime string (e.g., "2024-01-01T12:00:00Z").
352+
353+
Returns:
354+
API response dict for the scheduled draft.
355+
"""
356+
from datetime import datetime
357+
358+
try:
359+
draft_datetime = datetime.fromisoformat(at.replace("Z", "+00:00"))
360+
except ValueError as e:
361+
raise ValueError(f"Invalid ISO datetime string for 'at': {e}")
362+
363+
client = get_api()
364+
return client.schedule_draft(draft_id, draft_datetime)
365+
366+
367+
@mcp.tool()
368+
async def unschedule_draft(draft_id: int) -> Dict[str, Any]:
369+
"""Unschedule a previously scheduled draft.
370+
371+
Args:
372+
draft_id: target draft identifier.
373+
374+
Returns:
375+
API response dict for unscheduling.
376+
"""
377+
client = get_api()
378+
return client.unschedule_draft(draft_id)
379+
380+
288381
def main() -> None:
289382
mcp.run(transport="stdio")
290383

0 commit comments

Comments
 (0)