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,46 @@
1
+ """Create subscriber 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. Early readers."}},
17
+ "required": ["name"],
18
+ "additionalProperties": False,
19
+ }
20
+
21
+ EXAMPLE_RESPONSE: dict[str, Any] = {"tag_id": "9357", "name": "Early readers"}
22
+
23
+
24
+ @tool(
25
+ name="create_subscriber_tag",
26
+ title="Create subscriber tag",
27
+ access="write",
28
+ confirm=False,
29
+ description="New private tag for grouping subscribers.",
30
+ details=(
31
+ "Creates a subscriber tag on your publication. Tags are private: subscribers never see them. Put "
32
+ "subscribers under it with tag_subscribers."
33
+ ),
34
+ input_schema=INPUT_SCHEMA,
35
+ route="POST /subscriber-tags",
36
+ example_response=EXAMPLE_RESPONSE,
37
+ )
38
+ async def create_subscriber_tag(ctx: ToolContext, args: dict[str, Any]) -> Any:
39
+
40
+ name = str(args["name"]).strip()
41
+ if not name or len(name) > 100:
42
+ raise ToolError("name must be 1 to 100 characters.", ErrorCode.INVALID_INPUT)
43
+ pub = await publication(ctx)
44
+ answer = await call(ctx, "POST", f"{pub['base']}/api/v1/subscriber-tags", body={"name": name})
45
+ tag = answer.get("tag") or answer
46
+ return {"tag_id": str(tag["id"]), "name": tag.get("name") or name}
@@ -0,0 +1,45 @@
1
+ """Delete comment or Note."""
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, comment_id
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": "ID of your comment, Note or Note reply."},
17
+ },
18
+ "required": ["comment_id"],
19
+ "additionalProperties": False,
20
+ }
21
+
22
+ EXAMPLE_RESPONSE: dict[str, Any] = {"comment_id": "364150154", "deleted": True}
23
+
24
+
25
+ @tool(
26
+ name="delete_comment",
27
+ title="Delete comment or Note",
28
+ access="write",
29
+ confirm=True,
30
+ description="Delete one of your own comments, Notes or Note replies.",
31
+ details=(
32
+ "Deletes something you wrote: a comment under a post, a Note or a reply to a Note. Replies other "
33
+ "people wrote under it stay, under a deleted placeholder. Someone else's comment returns an error; "
34
+ "on your own posts remove it with remove_comment. By default you confirm each call; you can switch "
35
+ "that off for this tool."
36
+ ),
37
+ input_schema=INPUT_SCHEMA,
38
+ route="DELETE /comments/{comment_id}",
39
+ example_response=EXAMPLE_RESPONSE,
40
+ )
41
+ async def delete_comment(ctx: ToolContext, args: dict[str, Any]) -> Any:
42
+
43
+ cid = comment_id(args["comment_id"])
44
+ await call(ctx, "DELETE", f"{WEB}/api/v1/comment/{cid}")
45
+ return {"comment_id": str(cid), "deleted": True}
@@ -0,0 +1,47 @@
1
+ """Delete 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
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
+ "post_id": {"type": "string", "description": "Post ID from list_posts."},
17
+ },
18
+ "required": ["post_id"],
19
+ "additionalProperties": False,
20
+ }
21
+
22
+ EXAMPLE_RESPONSE: dict[str, Any] = {"post_id": "148213977", "title": "Notes on slow mornings", "deleted": True}
23
+
24
+
25
+ @tool(
26
+ name="delete_post",
27
+ title="Delete post",
28
+ access="write",
29
+ confirm=True,
30
+ description="Delete a draft, scheduled or published post, with its comments.",
31
+ details=(
32
+ "Deletes one post of your publication for good: a draft, a scheduled post (its send is cancelled) or "
33
+ "a published post, which leaves the web, the archive and the app with its comments and stats. Emails "
34
+ "already sent stay in inboxes. Substack has no undo; unpublish_post keeps the post as a draft instead. "
35
+ "By default you confirm each call; you can switch that off for this tool."
36
+ ),
37
+ input_schema=INPUT_SCHEMA,
38
+ route="DELETE /posts/{post_id}",
39
+ example_response=EXAMPLE_RESPONSE,
40
+ )
41
+ async def delete_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
42
+
43
+ pub = await publication(ctx)
44
+ pid = post_id(args["post_id"])
45
+ post = await draft(ctx, pub, pid)
46
+ await call(ctx, "DELETE", f"{pub['base']}/api/v1/drafts/{pid}")
47
+ return {"post_id": str(pid), "title": post.get("title") or post.get("draft_title") or "", "deleted": True}
@@ -0,0 +1,55 @@
1
+ """Duplicate 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, post_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
+ "post_id": {"type": "string", "description": "Post ID from list_posts."},
17
+ },
18
+ "required": ["post_id"],
19
+ "additionalProperties": False,
20
+ }
21
+
22
+ EXAMPLE_RESPONSE: dict[str, Any] = {
23
+ "post_id": "148214502",
24
+ "source_post_id": "148213977",
25
+ "title": "[COPY] Notes on slow mornings",
26
+ "edit_url": "https://yourname.substack.com/publish/post/148214502",
27
+ }
28
+
29
+
30
+ @tool(
31
+ name="duplicate_post",
32
+ title="Duplicate post",
33
+ access="write",
34
+ confirm=False,
35
+ description="Copy any post into a new draft.",
36
+ details=(
37
+ "Makes a new draft with the title, subtitle, body and settings of a draft or published post, as "
38
+ "Duplicate in the post list does. Substack puts [COPY] in front of the title; change it with "
39
+ "update_draft. Nothing is published or sent."
40
+ ),
41
+ input_schema=INPUT_SCHEMA,
42
+ route="POST /posts/{post_id}/duplicate",
43
+ example_response=EXAMPLE_RESPONSE,
44
+ )
45
+ async def duplicate_post(ctx: ToolContext, args: dict[str, Any]) -> Any:
46
+
47
+ pub = await publication(ctx)
48
+ pid = post_id(args["post_id"])
49
+ copy = await call(ctx, "POST", f"{pub['base']}/api/v1/posts/{pid}/duplicate")
50
+ return {
51
+ "post_id": str(copy["id"]),
52
+ "source_post_id": str(pid),
53
+ "title": copy.get("draft_title") or "",
54
+ "edit_url": f"{pub['base']}/publish/post/{copy['id']}",
55
+ }
@@ -0,0 +1,42 @@
1
+ """Export subscribers."""
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 publication
9
+ from substack_api_mcp.substack.subscribers import export
10
+
11
+ if TYPE_CHECKING:
12
+ from substack_api_mcp.core.context import ToolContext
13
+
14
+ INPUT_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}, "required": [], "additionalProperties": False}
15
+
16
+ EXAMPLE_RESPONSE: dict[str, Any] = {
17
+ "download_url": "https://substack-post-media.s3.us-east-1.amazonaws.com/publication_export/7f3c2a.csv?X-Amz-Expires=86400&X-Amz-Signature=...",
18
+ "rows": 4380,
19
+ "expires_at": "2026-10-12T10:00:00+00:00",
20
+ }
21
+
22
+
23
+ @tool(
24
+ name="export_subscribers",
25
+ title="Export subscribers",
26
+ access="read",
27
+ confirm=False,
28
+ description="The full subscriber list as a CSV download link, with the row count.",
29
+ details=(
30
+ "Builds the same CSV as Export in the subscriber dashboard: email, name, subscription type, activity "
31
+ "rating and start date. Substack builds the file in the background; the call waits up to about a "
32
+ "minute and otherwise asks you to try again later. The link needs no sign-in and works until "
33
+ "expires_at, 24 hours when we recorded it. For filtered pages use list_subscribers."
34
+ ),
35
+ input_schema=INPUT_SCHEMA,
36
+ route="GET /subscribers/export",
37
+ example_response=EXAMPLE_RESPONSE,
38
+ )
39
+ async def export_subscribers(ctx: ToolContext, args: dict[str, Any]) -> Any:
40
+
41
+ pub = await publication(ctx)
42
+ return await export(ctx, pub)
@@ -0,0 +1,46 @@
1
+ """Follow user."""
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, user_ref
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
+ "user": {"type": "string", "description": "Handle (e.g. @mayachen) or user ID."},
17
+ "unfollow": {"type": "boolean", "description": "Unfollow instead. Default: false."},
18
+ },
19
+ "required": ["user"],
20
+ "additionalProperties": False,
21
+ }
22
+
23
+ EXAMPLE_RESPONSE: dict[str, Any] = {"user_id": "41856304", "handle": "mayachen", "following": True}
24
+
25
+
26
+ @tool(
27
+ name="follow_user",
28
+ title="Follow user",
29
+ access="write",
30
+ confirm=False,
31
+ description="Follow or unfollow a Substack user, so their Notes reach your feed.",
32
+ details=(
33
+ "Follows a person on Substack, as Follow on their profile does, or unfollows them. Following is not "
34
+ "subscribing: you get their Notes and activity in the app, not their newsletter by email. They see "
35
+ "the follow."
36
+ ),
37
+ input_schema=INPUT_SCHEMA,
38
+ route="PUT /users/{user}/follow",
39
+ example_response=EXAMPLE_RESPONSE,
40
+ )
41
+ async def follow_user(ctx: ToolContext, args: dict[str, Any]) -> Any:
42
+
43
+ user = await user_ref(ctx, args["user"])
44
+ unfollow = bool(args.get("unfollow"))
45
+ await call(ctx, "DELETE" if unfollow else "POST", f"{WEB}/api/v1/feed/{user['id']}/follow")
46
+ return {"user_id": str(user["id"]), "handle": user["handle"], "following": not unfollow}
@@ -0,0 +1,93 @@
1
+ """Get audience insights."""
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, 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
+ "region": {
17
+ "type": "string",
18
+ "enum": ["world", "usa"],
19
+ "description": "world (by country) or usa (by state). Default: world.",
20
+ },
21
+ "paid_only": {"type": "boolean", "description": "Count paid subscribers only. Default: false."},
22
+ },
23
+ "required": [],
24
+ "additionalProperties": False,
25
+ }
26
+
27
+ EXAMPLE_RESPONSE: dict[str, Any] = {
28
+ "region": "world",
29
+ "metric": "all subscribers",
30
+ "countries_total": 41,
31
+ "us_states_total": 38,
32
+ "locations": [{"location": "us", "subscribers": 2310}, {"location": "gb", "subscribers": 402}],
33
+ "overlap": [
34
+ {
35
+ "publication_id": "5262902",
36
+ "name": "Brain Health Decoded",
37
+ "url": "https://example.substack.com",
38
+ "overlap_percent": 6.4,
39
+ }
40
+ ],
41
+ }
42
+
43
+
44
+ @tool(
45
+ name="get_audience_insights",
46
+ title="Get audience insights",
47
+ access="read",
48
+ confirm=False,
49
+ description="Where your subscribers live, and which publications share your readers.",
50
+ details=(
51
+ "Returns Analytics > Statistics > Audience: subscriber counts per country (region world, ISO codes "
52
+ "such as us, gb) or per US state (region usa, codes such as ny, ca), all subscribers or paid only, "
53
+ "the number of countries and states with any subscriber, and the publications whose readers "
54
+ "overlap most with yours. Substack computes these in the background; a new publication has empty "
55
+ "lists for a while."
56
+ ),
57
+ input_schema=INPUT_SCHEMA,
58
+ route="GET /audience",
59
+ example_response=EXAMPLE_RESPONSE,
60
+ )
61
+ async def get_audience_insights(ctx: ToolContext, args: dict[str, Any]) -> Any:
62
+
63
+ region = args.get("region") or "world"
64
+ metric = "paid subscribers" if args.get("paid_only") else "free signups"
65
+ pub = await publication(ctx)
66
+ stats = f"{pub['base']}/api/v1/publication/stats/audience_insights"
67
+ params = {"metric": metric, "granularity": "usa" if region == "usa" else "global"}
68
+ rows = await call(ctx, "GET", f"{stats}/location", params=params) or []
69
+ totals = await call(ctx, "GET", f"{stats}/location/total") or {}
70
+ overlap = await call(ctx, "GET", f"{stats}/overlap", params={"limit": 10}) or []
71
+ locations = sorted(
72
+ (
73
+ {"location": str(row.get("location") or "").lower(), "subscribers": int(row.get("value") or 0)}
74
+ for row in rows
75
+ ),
76
+ key=lambda row: -row["subscribers"],
77
+ )
78
+ return {
79
+ "region": region,
80
+ "metric": "paid subscribers" if args.get("paid_only") else "all subscribers",
81
+ "countries_total": int((totals.get("global") or {}).get("locations") or 0),
82
+ "us_states_total": int((totals.get("usa") or {}).get("locations") or 0),
83
+ "locations": locations,
84
+ "overlap": [
85
+ {
86
+ "publication_id": str((row.get("pub") or {}).get("id") or ""),
87
+ "name": (row.get("pub") or {}).get("name") or "",
88
+ "url": (row.get("pub") or {}).get("base_url"),
89
+ "overlap_percent": row.get("percentOverlap"),
90
+ }
91
+ for row in overlap
92
+ ],
93
+ }
@@ -0,0 +1,71 @@
1
+ """Get conversation."""
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, person
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
+ "conversation_id": {"type": "string", "description": "Conversation ID from list_conversations."},
18
+ },
19
+ "required": ["conversation_id"],
20
+ "additionalProperties": False,
21
+ }
22
+
23
+ EXAMPLE_RESPONSE: dict[str, Any] = {
24
+ "conversation_id": "112992a4-5356-4bd2-8a16-f940cabbb4df",
25
+ "members": [{"user_id": "41856304", "name": "Maya Chen", "handle": "mayachen"}],
26
+ "messages": [
27
+ {
28
+ "from": {"user_id": "561078443", "name": "Sam Ortiz", "handle": "samortiz"},
29
+ "body": "Hi, saw your Substack launch. Good luck with it!",
30
+ "sent_at": "2026-10-18T15:55:22.545Z",
31
+ }
32
+ ],
33
+ }
34
+
35
+
36
+ @tool(
37
+ name="get_conversation",
38
+ title="Get conversation",
39
+ access="read",
40
+ confirm=False,
41
+ description="The latest messages of one direct-message conversation, oldest first.",
42
+ details=(
43
+ "Returns who is in the conversation and its latest messages as the chat window first shows them, "
44
+ "oldest first, with sender, text and time. Opening a conversation this way marks nothing read."
45
+ ),
46
+ input_schema=INPUT_SCHEMA,
47
+ route="GET /conversations/{conversation_id}",
48
+ example_response=EXAMPLE_RESPONSE,
49
+ )
50
+ async def get_conversation(ctx: ToolContext, args: dict[str, Any]) -> Any:
51
+
52
+ thread_id = str(args["conversation_id"]).strip().removeprefix("direct-message-")
53
+ if not thread_id or "/" in thread_id:
54
+ raise ToolError("conversation_id must be the ID from list_conversations.", ErrorCode.INVALID_INPUT)
55
+ answer = await call(ctx, "GET", f"{WEB}/api/v1/messages/dm/{thread_id}")
56
+ thread = answer.get("thread") or {}
57
+ messages = [
58
+ {
59
+ "from": person(row.get("user") or {"id": (row.get("comment") or {}).get("user_id")}),
60
+ "body": (row.get("comment") or {}).get("body") or "",
61
+ "sent_at": (row.get("comment") or {}).get("created_at"),
62
+ }
63
+ for row in answer.get("replies") or []
64
+ if row.get("comment")
65
+ ]
66
+ messages.sort(key=lambda message: message["sent_at"] or "")
67
+ return {
68
+ "conversation_id": thread.get("id") or thread_id,
69
+ "members": [person(member.get("user")) for member in thread.get("members") or []],
70
+ "messages": messages,
71
+ }
@@ -0,0 +1,77 @@
1
+ """Get daily stats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime, timedelta
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 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": {"period": {"type": "string", "enum": ["7d", "30d", "90d", "365d"], "description": "Default: 30d."}},
17
+ "required": [],
18
+ "additionalProperties": False,
19
+ }
20
+
21
+ EXAMPLE_RESPONSE: dict[str, Any] = {
22
+ "period": "7d",
23
+ "days": [{"date": "2026-10-18", "subscribers": 4380, "paid_subscribers": 412, "followers": 5120, "views": 1893}],
24
+ }
25
+
26
+
27
+ @tool(
28
+ name="get_daily_stats",
29
+ title="Get daily stats",
30
+ access="read",
31
+ confirm=False,
32
+ description="Day by day: total and paid subscribers, followers and views.",
33
+ details=(
34
+ "Returns one row per day of the period, as the charts on the dashboard home and in Audience: total "
35
+ "subscribers, paid subscribers and followers at the end of the day, and the day's views across your "
36
+ "site and the app. Days Substack has no number for are null; a young publication starts on the day "
37
+ "it was made."
38
+ ),
39
+ input_schema=INPUT_SCHEMA,
40
+ route="GET /stats/daily",
41
+ example_response=EXAMPLE_RESPONSE,
42
+ )
43
+ async def get_daily_stats(ctx: ToolContext, args: dict[str, Any]) -> Any:
44
+
45
+ period = args.get("period") or "30d"
46
+ pub = await publication(ctx)
47
+ since = (datetime.now(UTC) - timedelta(days=int(period[:-1]))).strftime("%Y-%m-%dT%H:%M:%S.000Z")
48
+ stats = f"{pub['base']}/api/v1/publication/stats"
49
+ columns = {
50
+ "subscribers": await _series(ctx, f"{stats}/emails/timeseries", since),
51
+ "paid_subscribers": await _series(ctx, f"{stats}/subscribers/timeseries", since, "Paid"),
52
+ "followers": await _series(ctx, f"{stats}/followers/timeseries", since),
53
+ "views": await _series(ctx, f"{stats}/publication_traffic/timeseries", since, resolution="day"),
54
+ }
55
+ dates = sorted({date for series in columns.values() for date in series})
56
+ return {
57
+ "period": period,
58
+ "days": [{"date": date, **{name: series.get(date) for name, series in columns.items()}} for date in dates],
59
+ }
60
+
61
+
62
+ async def _series(
63
+ ctx: ToolContext, url: str, since: str, column: str | None = None, resolution: str | None = None
64
+ ) -> dict[str, int | None]:
65
+ """A dashboard chart as {date: value}. Rows are [date, value]; with a header row, `column` picks the value."""
66
+ params = {"from": since, **({"resolution": resolution} if resolution else {})}
67
+ rows = await call(ctx, "GET", url, params=params) or []
68
+ index = 1
69
+ if rows and isinstance(rows[0], list) and rows[0] and rows[0][0] == "Date":
70
+ index = rows[0].index(column) if column in rows[0] else 1
71
+ rows = rows[1:]
72
+ series: dict[str, int | None] = {}
73
+ for row in rows:
74
+ if isinstance(row, list) and len(row) > index:
75
+ value = row[index]
76
+ series[str(row[0]).replace("/", "-")] = int(value) if isinstance(value, int | float) else None
77
+ return series
@@ -0,0 +1,83 @@
1
+ """Get feed."""
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
+ from substack_api_mcp.substack.feed import note_item, post_item
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
+ "tab": {
18
+ "type": "string",
19
+ "enum": ["for_you", "following"],
20
+ "description": "for_you (Substack's picks) or following (whom you follow). Default: for_you.",
21
+ },
22
+ "cursor": {"type": "string", "description": "next_cursor from the previous page."},
23
+ },
24
+ "required": [],
25
+ "additionalProperties": False,
26
+ }
27
+
28
+ EXAMPLE_RESPONSE: dict[str, Any] = {
29
+ "tab": "following",
30
+ "posts": [
31
+ {
32
+ "post_id": "197396217",
33
+ "title": "A Guide to Your Morning Routine",
34
+ "publication": "Brain Health Decoded",
35
+ "publication_id": "5262902",
36
+ "published_at": "2026-05-12T19:25:47.700Z",
37
+ "likes": 410,
38
+ "url": "https://example.substack.com/p/a-guide-to-your-morning-routine",
39
+ }
40
+ ],
41
+ "notes": [
42
+ {
43
+ "note_id": "322981144",
44
+ "author": {"user_id": "18270011", "name": "Laura Mills", "handle": "lauramills"},
45
+ "body": "Morning light beats any supplement.",
46
+ "published_at": "2026-09-02T07:11:00.000Z",
47
+ "likes": 88,
48
+ }
49
+ ],
50
+ "next_cursor": "eyJzZXNzaW9uX2lkIjoiZWQzOCJ9",
51
+ }
52
+
53
+
54
+ @tool(
55
+ name="get_feed",
56
+ title="Get feed",
57
+ access="read",
58
+ confirm=False,
59
+ description="Your Substack home feed: posts and Notes, For you or Following.",
60
+ details=(
61
+ "Returns one page of the home feed of the Substack app, split into posts and Notes in feed order. "
62
+ "for_you is Substack's own mix; following holds only people and publications you follow or "
63
+ "subscribe to. Suggestions of people to follow are left out. Reading marks nothing seen. Page with "
64
+ "next_cursor; the feed is personal and changes between calls."
65
+ ),
66
+ input_schema=INPUT_SCHEMA,
67
+ route="GET /feed",
68
+ example_response=EXAMPLE_RESPONSE,
69
+ )
70
+ async def get_feed(ctx: ToolContext, args: dict[str, Any]) -> Any:
71
+
72
+ tab = args.get("tab") or "for_you"
73
+ params = {"tab": "subscribed" if tab == "following" else "for-you", "type": "base"}
74
+ if args.get("cursor"):
75
+ params["cursor"] = args["cursor"]
76
+ answer = await call(ctx, "GET", f"{WEB}/api/v1/reader/feed", params=params)
77
+ items = answer.get("items") or []
78
+ return {
79
+ "tab": tab,
80
+ "posts": [post_item(item) for item in items if item.get("type") == "post" and item.get("post")],
81
+ "notes": [note_item(item) for item in items if item.get("type") == "comment" and item.get("comment")],
82
+ "next_cursor": answer.get("nextCursor"),
83
+ }