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,106 @@
1
+ """All tools of the app in one registry, and the one way to call them: validate, run, map errors.
2
+
3
+ The REST API, the MCP server, the CLI and `substack_api_mcp.call()` all go through `Runner.run`.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import importlib
10
+ import pkgutil
11
+ from typing import Any
12
+
13
+ import jsonschema
14
+
15
+ from substack_api_mcp import app
16
+ from substack_api_mcp import session as sessions
17
+ from substack_api_mcp.core.context import ToolContext
18
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
19
+ from substack_api_mcp.core.tools import ToolSpec
20
+ from substack_api_mcp.substack import tools as tools_package
21
+
22
+ _TOOLS: dict[str, ToolSpec] | None = None
23
+
24
+
25
+ def all_tools() -> dict[str, ToolSpec]:
26
+ """Every tool in the app's tools/ package, by name, in file order."""
27
+ global _TOOLS
28
+ if _TOOLS is None:
29
+ found: dict[str, ToolSpec] = {}
30
+ for module_info in sorted(pkgutil.iter_modules(tools_package.__path__), key=lambda m: m.name):
31
+ module = importlib.import_module(f"{tools_package.__name__}.{module_info.name}")
32
+ for value in vars(module).values():
33
+ if isinstance(value, ToolSpec) and value.route:
34
+ found[value.name] = value
35
+ _TOOLS = found
36
+ return _TOOLS
37
+
38
+
39
+ def tools(read_only: bool = False) -> list[ToolSpec]:
40
+ return [spec for spec in all_tools().values() if not read_only or spec.access == "read"]
41
+
42
+
43
+ def description(spec: ToolSpec) -> str:
44
+ """What an agent reads: the one-liner, then the details."""
45
+ return f"{spec.description}\n\n{spec.details}" if spec.details else spec.description
46
+
47
+
48
+ class Runner:
49
+ """One session for the life of the process; reconnects to Chrome once when the app signs it out."""
50
+
51
+ def __init__(self, read_only: bool = False) -> None:
52
+ self.read_only = read_only
53
+ self._ctx: ToolContext | None = None
54
+ self._lock = asyncio.Lock()
55
+
56
+ def _context(self) -> ToolContext:
57
+ if self._ctx is None:
58
+ session = sessions.load()
59
+ if not session.signed_in:
60
+ raise ToolError(
61
+ f"No {app.NAME} session yet. Sign in to {app.NAME} in Chrome, then run: {app.CLI} connect",
62
+ ErrorCode.AUTH_REQUIRED,
63
+ )
64
+ self._ctx = ToolContext(session=session)
65
+ return self._ctx
66
+
67
+ async def run(self, name: str, args: dict[str, Any] | None = None) -> Any:
68
+ spec = all_tools().get(name)
69
+ if spec is None:
70
+ raise ToolError(f"No tool '{name}'. List them with: {app.CLI} tools", ErrorCode.UNKNOWN_TOOL)
71
+ if self.read_only and spec.access != "read":
72
+ raise ToolError(f"{name} changes {app.NAME} and this server runs read-only.", ErrorCode.SCOPE_DENIED)
73
+ args = {k: v for k, v in (args or {}).items() if v is not None}
74
+ try:
75
+ jsonschema.validate(args, spec.input_schema)
76
+ except jsonschema.ValidationError as exc:
77
+ where = ".".join(str(p) for p in exc.absolute_path)
78
+ raise ToolError(f"{where + ': ' if where else ''}{exc.message}", ErrorCode.INVALID_INPUT) from None
79
+ ctx = self._context()
80
+ try:
81
+ return await asyncio.wait_for(spec.handler(ctx, args), timeout=spec.timeout_s or 120)
82
+ except TimeoutError:
83
+ raise ToolError(f"{app.NAME} took too long to answer.", ErrorCode.TIMEOUT) from None
84
+ except ToolError as exc:
85
+ if exc.code is not ErrorCode.AUTH_EXPIRED or not await self._reconnect():
86
+ raise
87
+ # The app signed the old cookies out: read them again from Chrome and try once more.
88
+ return await spec.handler(self._context(), args)
89
+
90
+ async def _reconnect(self) -> bool:
91
+ async with self._lock:
92
+ old = self._context()
93
+ try:
94
+ fresh = await asyncio.to_thread(sessions.refresh, old.session)
95
+ except ToolError:
96
+ return False
97
+ if fresh is None or fresh.cookies == old.session.cookies:
98
+ return False
99
+ await old.close()
100
+ self._ctx = ToolContext(session=fresh)
101
+ return True
102
+
103
+ async def close(self) -> None:
104
+ if self._ctx is not None:
105
+ await self._ctx.close()
106
+ self._ctx = None
@@ -0,0 +1,73 @@
1
+ """Helpers for scripts: call a tool, follow pages, save CSV or JSON. The scripts in examples/ use them.
2
+
3
+ from substack_api_mcp.scripting import call, paginate
4
+
5
+ for member in paginate("list_members", {"group": "yourgroup"}, "members"):
6
+ print(member["name"], member["level"])
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ import json
13
+ import sys
14
+ from collections.abc import Callable, Iterator
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from substack_api_mcp import call as _call
19
+ from substack_api_mcp.core.errors import ToolError
20
+
21
+
22
+ def call(tool: str, params: dict[str, Any] | None = None) -> Any:
23
+ """Run one tool; arguments set to None are left out."""
24
+ return _call(tool, {k: v for k, v in (params or {}).items() if v is not None})
25
+
26
+
27
+ def paginate(tool: str, params: dict[str, Any], key: str, max_items: int | None = None) -> Iterator[dict[str, Any]]:
28
+ """Yield every item of a list tool, following next_cursor."""
29
+ params = dict(params)
30
+ seen = 0
31
+ while True:
32
+ page = call(tool, params)
33
+ for item in page.get(key, []):
34
+ yield item
35
+ seen += 1
36
+ if max_items and seen >= max_items:
37
+ return
38
+ cursor = page.get("next_cursor")
39
+ if not cursor:
40
+ return
41
+ params["cursor"] = cursor
42
+
43
+
44
+ def write_rows(rows: list[dict[str, Any]], out: str | None) -> None:
45
+ """Print JSON to stdout, or save .csv / .json when a path is given."""
46
+ if not out:
47
+ print(json.dumps(rows, indent=2, ensure_ascii=False))
48
+ return
49
+ path = Path(out)
50
+ if path.suffix.lower() == ".csv":
51
+ fields: list[str] = []
52
+ for row in rows:
53
+ fields += [k for k in row if k not in fields]
54
+ with path.open("w", newline="", encoding="utf-8") as fh:
55
+ writer = csv.DictWriter(fh, fieldnames=fields)
56
+ writer.writeheader()
57
+ for row in rows:
58
+ writer.writerow(
59
+ {k: json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v for k, v in row.items()}
60
+ )
61
+ else:
62
+ path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
63
+ print(f"Saved {len(rows)} rows to {path}", file=sys.stderr)
64
+
65
+
66
+ def run(main: Callable[[], None]) -> None:
67
+ """Run a script's main() and turn tool errors into a readable message."""
68
+ try:
69
+ main()
70
+ except ToolError as err:
71
+ sys.exit(f"{err.code}: {err}")
72
+ except KeyboardInterrupt:
73
+ sys.exit(130)
@@ -0,0 +1,197 @@
1
+ """Your session of the app: its cookies from your own Chrome, saved on this machine.
2
+
3
+ `<cli> connect` reads them from Chrome over the Chrome DevTools Protocol, in one of two ways:
4
+
5
+ - Chrome 144+ with remote debugging switched on at chrome://inspect/#remote-debugging. Chrome writes
6
+ the port to `DevToolsActivePort` in its profile folder and asks you to allow each connection.
7
+ - Any Chrome started with `--remote-debugging-port=9222 --user-data-dir=<folder>`: pass `--cdp http://127.0.0.1:9222`.
8
+
9
+ The session file holds cookies only, never your password, and is readable by your user alone (0600).
10
+ Names, domains and the session cookie come from `app.py`.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import platform
18
+ import re
19
+ import stat
20
+ import urllib.request
21
+ from dataclasses import asdict, dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import urlsplit
25
+
26
+ from substack_api_mcp import app
27
+ from substack_api_mcp.core.errors import ErrorCode, ToolError
28
+
29
+ SESSION_COOKIE = app.SESSION_COOKIES[0]
30
+
31
+
32
+ def home() -> Path:
33
+ """Where the session is kept: $<ENV>_MCP_HOME, or ~/.config/<slug>-api-mcp."""
34
+ return Path(os.environ.get(f"{app.ENV}_MCP_HOME") or Path.home() / ".config" / f"{app.SLUG}-api-mcp")
35
+
36
+
37
+ def session_path() -> Path:
38
+ return home() / "session.json"
39
+
40
+
41
+ @dataclass
42
+ class Session:
43
+ cookies: list[dict[str, Any]] = field(default_factory=list)
44
+ user_agent: str | None = None
45
+ chrome_major: int | None = None
46
+ locale: str | None = None
47
+ # Where the cookies came from, so a signed-out session can be read again: "auto", a CDP URL, or "token".
48
+ source: str | None = None
49
+ proxy: str | None = None
50
+
51
+ @property
52
+ def signed_in(self) -> bool:
53
+ return all(any(c["name"] == name and c.get("value") for c in self.cookies) for name in app.SESSION_COOKIES)
54
+
55
+
56
+ def load() -> Session:
57
+ """The saved session, with <ENV>_AUTH_TOKEN and <ENV>_PROXY from the environment on top."""
58
+ session = Session()
59
+ path = session_path()
60
+ if path.exists():
61
+ data = json.loads(path.read_text(encoding="utf-8"))
62
+ session = Session(**{k: v for k, v in data.items() if k in Session.__dataclass_fields__})
63
+ if token := os.environ.get(f"{app.ENV}_AUTH_TOKEN", "").strip():
64
+ session.cookies = [c for c in session.cookies if c["name"] != SESSION_COOKIE]
65
+ session.cookies.append({"name": SESSION_COOKIE, "value": token, "domain": app.COOKIE_DOMAIN, "path": "/"})
66
+ session.source = session.source or "token"
67
+ if proxy := os.environ.get(f"{app.ENV}_PROXY", "").strip():
68
+ session.proxy = proxy
69
+ return session
70
+
71
+
72
+ def save(session: Session) -> Path:
73
+ path = session_path()
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, stat.S_IRUSR | stat.S_IWUSR)
76
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
77
+ json.dump(asdict(session), fh, indent=2)
78
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
79
+ return path
80
+
81
+
82
+ def from_token(token: str) -> Session:
83
+ """A session from the auth_token value copied by hand (Chrome DevTools > Application > Cookies)."""
84
+ token = token.strip()
85
+ if not token:
86
+ raise ToolError("The token is empty.", ErrorCode.INVALID_INPUT)
87
+ cookie = {"name": SESSION_COOKIE, "value": token, "domain": app.COOKIE_DOMAIN, "path": "/"}
88
+ return Session(cookies=[cookie], source="token")
89
+
90
+
91
+ def chrome_profile_dirs() -> list[Path]:
92
+ """Default Chrome profile folders of this OS, stable channel first."""
93
+ system = platform.system()
94
+ if system == "Darwin":
95
+ base = Path.home() / "Library" / "Application Support" / "Google"
96
+ return [base / "Chrome", base / "Chrome Beta", base / "Chrome Canary"]
97
+ if system == "Windows":
98
+ base = Path(os.environ.get("LOCALAPPDATA", "")) / "Google"
99
+ return [base / "Chrome" / "User Data", base / "Chrome Beta" / "User Data", base / "Chrome SxS" / "User Data"]
100
+ base = Path.home() / ".config"
101
+ return [base / "google-chrome", base / "google-chrome-beta", base / "chromium"]
102
+
103
+
104
+ def auto_endpoint(profile_dir: str | None = None) -> str:
105
+ """The browser WebSocket URL from DevToolsActivePort, written while remote debugging is on."""
106
+ dirs = [Path(profile_dir).expanduser()] if profile_dir else chrome_profile_dirs()
107
+ for folder in dirs:
108
+ port_file = folder / "DevToolsActivePort"
109
+ if port_file.exists():
110
+ lines = port_file.read_text(encoding="utf-8").split()
111
+ if len(lines) >= 2 and lines[0].isdigit():
112
+ return f"ws://127.0.0.1:{lines[0]}{lines[1]}"
113
+ raise ToolError(
114
+ "Chrome remote debugging is off. Open chrome://inspect/#remote-debugging in Chrome, switch it on, "
115
+ "and run this again. Or start Chrome with --remote-debugging-port=9222 and pass --cdp http://127.0.0.1:9222.",
116
+ ErrorCode.AUTH_REQUIRED,
117
+ )
118
+
119
+
120
+ def _endpoint(cdp: str) -> str:
121
+ """A ws:// URL as given, or the browser WebSocket URL behind an http:// DevTools address."""
122
+ if cdp.startswith(("ws://", "wss://")):
123
+ return cdp
124
+ with urllib.request.urlopen(cdp.rstrip("/") + "/json/version", timeout=10) as resp:
125
+ return json.loads(resp.read())["webSocketDebuggerUrl"]
126
+
127
+
128
+ class _Cdp:
129
+ """A tiny synchronous CDP client: one WebSocket to the browser target."""
130
+
131
+ def __init__(self, url: str) -> None:
132
+ from websockets.sync.client import connect
133
+
134
+ # Chrome shows "Allow remote debugging?" for this connection; give the user time to click it.
135
+ self._ws = connect(url, open_timeout=60, max_size=64 * 1024 * 1024)
136
+ self._id = 0
137
+
138
+ def send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
139
+ self._id += 1
140
+ self._ws.send(json.dumps({"id": self._id, "method": method, "params": params or {}}))
141
+ while True:
142
+ message = json.loads(self._ws.recv(timeout=60))
143
+ if message.get("id") != self._id:
144
+ continue
145
+ if "error" in message:
146
+ raise ToolError(f"Chrome refused {method}: {message['error'].get('message')}", ErrorCode.INTERNAL_ERROR)
147
+ return message.get("result") or {}
148
+
149
+ def close(self) -> None:
150
+ self._ws.close()
151
+
152
+
153
+ def from_chrome(cdp: str | None = None, profile_dir: str | None = None) -> Session:
154
+ """Read the app's cookies of a running Chrome. Nothing is changed in the browser."""
155
+ url = _endpoint(cdp) if cdp else auto_endpoint(profile_dir)
156
+ try:
157
+ client = _Cdp(url)
158
+ except Exception as exc:
159
+ raise ToolError(
160
+ f"Could not connect to Chrome at {urlsplit(url).netloc} ({type(exc).__name__}). "
161
+ "Is Chrome open, and did you click Allow in its remote debugging prompt?",
162
+ ErrorCode.AUTH_REQUIRED,
163
+ ) from exc
164
+ try:
165
+ version = client.send("Browser.getVersion")
166
+ cookies = client.send("Storage.getCookies").get("cookies") or []
167
+ finally:
168
+ client.close()
169
+ mine = [
170
+ {"name": c["name"], "value": c["value"], "domain": c["domain"], "path": c.get("path") or "/"}
171
+ for c in cookies
172
+ if any(c.get("domain", "").lstrip(".").endswith(domain) for domain in app.DOMAINS)
173
+ ]
174
+ user_agent = version.get("userAgent")
175
+ match = re.search(r"Chrome/(\d+)", user_agent or "") or re.search(r"/(\d+)\.", version.get("product", ""))
176
+ session = Session(
177
+ cookies=mine,
178
+ user_agent=user_agent,
179
+ chrome_major=int(match.group(1)) if match else None,
180
+ source=cdp or "auto",
181
+ )
182
+ if not session.signed_in:
183
+ raise ToolError(
184
+ f"This Chrome is not signed in to {app.NAME}. Sign in at {app.LOGIN_URL}, then run this again.",
185
+ ErrorCode.AUTH_REQUIRED,
186
+ )
187
+ return session
188
+
189
+
190
+ def refresh(session: Session) -> Session | None:
191
+ """Read the cookies again from where they came from; None when the source is a hand-copied token."""
192
+ if not session.source or session.source == "token":
193
+ return None
194
+ fresh = from_chrome(None if session.source == "auto" else session.source)
195
+ fresh.proxy = session.proxy
196
+ save(fresh)
197
+ return fresh
File without changes
@@ -0,0 +1,26 @@
1
+ """Who is signed in: Substack's own answer for the account (substack.com/api/v1/user/profile/self)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from substack_api_mcp.core.errors import AuthExpiredError
8
+ from substack_api_mcp.substack.client import WEB
9
+
10
+ if TYPE_CHECKING:
11
+ from substack_api_mcp.core.context import ToolContext
12
+
13
+
14
+ async def whoami(ctx: ToolContext) -> str | None:
15
+ """None only when Substack says signed out; any other failure raises (detection tries again later)."""
16
+ try:
17
+ response = await ctx.call("GET", f"{WEB}/api/v1/user/profile/self", headers={"accept": "application/json"})
18
+ except AuthExpiredError:
19
+ return None
20
+ if response.status != 200:
21
+ return None
22
+ me = response.json()
23
+ if not me.get("id"):
24
+ return None
25
+ name, handle = me.get("name") or "", me.get("handle") or ""
26
+ return f"{name} (@{handle})" if name and handle else name or (f"@{handle}" if handle else None)
@@ -0,0 +1,172 @@
1
+ """Substack web requests recorded on a test publication (17.10.2026).
2
+
3
+ Two hosts, both with the account's own cookies (`substack.sid` on .substack.com):
4
+ - `substack.com/api/v1`: the reader app (who am I, Notes, comment lookup).
5
+ - `{subdomain}.substack.com/api/v1`: the publisher dashboard of one publication (posts, drafts, stats,
6
+ subscribers, comments). Requests go to the subdomain even when the publication has a custom domain:
7
+ the custom domain is outside the manifest's domains, and Substack answers the same on both.
8
+ The tools work on the account's own publication: the first one it administers, as the dashboard opens it.
9
+ Answers keep Substack's ids as strings; times are ISO 8601 as Substack sends them. Cursors are offsets.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from substack_api_mcp.core.errors import ErrorCode, TargetBlockedError, ToolError
18
+ from substack_api_mcp.core.helpers import json_field, page_cursor
19
+
20
+ if TYPE_CHECKING:
21
+ from substack_api_mcp.core.context import ToolContext
22
+
23
+ WEB = "https://substack.com"
24
+ _PUBLISHER_ROLES = ("admin", "owner", "editor", "contributor")
25
+ # The preloads are a JSON string inside the page, so the quotes may come escaped.
26
+ _BANNED = re.compile(r'is_globally_banned\\*"\s*:\s*true')
27
+
28
+
29
+ async def me(ctx: ToolContext) -> dict[str, Any]:
30
+ """The signed-in user's profile with their publications (`publicationUsers`)."""
31
+ return await call(ctx, "GET", f"{WEB}/api/v1/user/profile/self")
32
+
33
+
34
+ async def publication(ctx: ToolContext) -> dict[str, Any]:
35
+ """The account's own publication: ids, base URL of its dashboard API and the author's byline id."""
36
+ profile = await me(ctx)
37
+ for row in profile.get("publicationUsers") or []:
38
+ pub = row.get("publication") or {}
39
+ if row.get("role") in _PUBLISHER_ROLES and pub.get("subdomain"):
40
+ return {
41
+ "id": pub.get("id"),
42
+ "name": pub.get("name"),
43
+ "subdomain": pub["subdomain"],
44
+ "base": f"https://{pub['subdomain']}.substack.com",
45
+ "user_id": profile.get("id"),
46
+ "handle": profile.get("handle"),
47
+ "role": row.get("role"),
48
+ }
49
+ if await _suspended(ctx):
50
+ raise TargetBlockedError(
51
+ "Substack suspended this account and hid its publication. The notice and the appeal link are on "
52
+ "substack.com in your Cloud Browser."
53
+ )
54
+ raise ToolError("This Substack account has no publication it can publish to.", ErrorCode.APP_PERMISSION_DENIED)
55
+
56
+
57
+ async def _suspended(ctx: ToolContext) -> bool:
58
+ """A suspended account still signs in, but its profile lists no publications (18.10.2026).
59
+
60
+ Only the page's preloaded user says why (`is_globally_banned`); no JSON endpoint carries it.
61
+ """
62
+ response = await ctx.call("GET", f"{WEB}/", headers={"accept": "text/html"})
63
+ return bool(_BANNED.search(response.text))
64
+
65
+
66
+ async def call(
67
+ ctx: ToolContext, method: str, url: str, *, params: dict[str, Any] | None = None, body: Any = None
68
+ ) -> Any:
69
+ """One Substack API request; errors become messages for the agent."""
70
+ host = url.split("/api/")[0]
71
+ headers = {"referer": f"{host}/", "origin": host, "accept": "application/json"}
72
+ response = await ctx.call(method, url, params=params, json=body, headers=headers)
73
+ if response.status == 404:
74
+ raise ToolError("Substack has no such item, or this account cannot see it.", ErrorCode.TARGET_ERROR)
75
+ if response.status == 429:
76
+ raise ToolError("Substack is limiting this account; try again in a few minutes.", ErrorCode.RATE_LIMITED)
77
+ if response.status >= 400:
78
+ raise ToolError(f"Substack refused the request ({response.status}).", ErrorCode.TARGET_ERROR)
79
+ if not response.text.strip():
80
+ return {}
81
+ try:
82
+ return response.json()
83
+ except ValueError as exc:
84
+ raise ToolError("Substack answered with a page instead of data.", ErrorCode.TARGET_ERROR) from exc
85
+
86
+
87
+ def offset_cursor(cursor: str | None) -> int:
88
+ """Offsets as cursors: none is the first page, anything else is a next_cursor we handed out."""
89
+ return page_cursor(cursor) if cursor else 0
90
+
91
+
92
+ def next_cursor(offset: int, got: int, wanted: int) -> str | None:
93
+ return str(offset + got) if got >= wanted else None
94
+
95
+
96
+ def comment_id(value: Any, source: str = "list_comments") -> int:
97
+ """Comments and Notes share one ID space; Note URLs prefix it with c-."""
98
+ text = str(value or "").strip().removeprefix("c-")
99
+ if not text.isdigit():
100
+ raise ToolError(f"comment_id must be the numeric ID from {source}.", ErrorCode.INVALID_INPUT)
101
+ return int(text)
102
+
103
+
104
+ async def user_ref(ctx: ToolContext, value: Any) -> dict[str, Any]:
105
+ """A Substack user by numeric ID or handle (with or without @): id, name and handle."""
106
+ text = str(value or "").strip().removeprefix("@")
107
+ if not re.fullmatch(r"[A-Za-z0-9_]{1,80}", text):
108
+ raise ToolError("user must be a Substack handle or a numeric user ID.", ErrorCode.INVALID_INPUT)
109
+ if text.isdigit():
110
+ # Profiles are looked up by handle only; a numeric ID goes straight to the request.
111
+ return {"id": int(text), "name": "", "handle": ""}
112
+ found = await call(ctx, "GET", f"{WEB}/api/v1/user/{text}/public_profile")
113
+ return {"id": int(found["id"]), "name": found.get("name") or "", "handle": found.get("handle") or ""}
114
+
115
+
116
+ def person(user: dict[str, Any] | None) -> dict[str, Any]:
117
+ """Who someone is in our answers: never their email."""
118
+ user = user or {}
119
+ return {
120
+ "user_id": str(user.get("id") or user.get("user_id") or ""),
121
+ "name": user.get("name") or "",
122
+ "handle": user.get("handle") or "",
123
+ }
124
+
125
+
126
+ def post_id(value: Any) -> int:
127
+ text = str(value or "").strip()
128
+ if not text.isdigit():
129
+ raise ToolError("post_id must be the numeric ID from list_posts.", ErrorCode.INVALID_INPUT)
130
+ return int(text)
131
+
132
+
133
+ def post_status(post: dict[str, Any]) -> str:
134
+ if post.get("is_published"):
135
+ return "published"
136
+ if post.get("trigger_at") or post.get("postSchedules"):
137
+ return "scheduled"
138
+ return "draft"
139
+
140
+
141
+ def post_url(pub: dict[str, Any], post: dict[str, Any]) -> str | None:
142
+ if not post.get("is_published") or not post.get("slug"):
143
+ return None
144
+ return post.get("canonical_url") or f"{pub['base']}/p/{post['slug']}"
145
+
146
+
147
+ def scheduled_at(post: dict[str, Any]) -> str | None:
148
+ if post.get("trigger_at"):
149
+ return post["trigger_at"]
150
+ schedules = post.get("postSchedules") or []
151
+ return schedules[0].get("trigger_at") if schedules else None
152
+
153
+
154
+ async def draft(ctx: ToolContext, pub: dict[str, Any], pid: int) -> dict[str, Any]:
155
+ """The editor's copy of a post: drafts, scheduled and published posts alike (body in `body`/`draft_body`)."""
156
+ return await call(ctx, "GET", f"{pub['base']}/api/v1/drafts/{pid}")
157
+
158
+
159
+ def require_draft(post: dict[str, Any]) -> None:
160
+ if post.get("is_published"):
161
+ raise ToolError("This post is already published; only drafts can be changed here.", ErrorCode.INVALID_INPUT)
162
+
163
+
164
+ def body_doc(post: dict[str, Any]) -> dict[str, Any]:
165
+ """The post's ProseMirror document: the published body, else the draft's."""
166
+ raw = post.get("body") if post.get("is_published") and post.get("body") else post.get("draft_body")
167
+ doc = json_field(raw)
168
+ return doc if isinstance(doc, dict) else {"type": "doc", "content": []}
169
+
170
+
171
+ def byline(pub: dict[str, Any]) -> list[dict[str, Any]]:
172
+ return [{"id": pub["user_id"], "is_guest": False}]
@@ -0,0 +1,31 @@
1
+ """Items of Substack's reader feeds (search, home feed): posts and Notes in one shape for our answers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from substack_api_mcp.substack.client import person
8
+
9
+
10
+ def post_item(item: dict[str, Any]) -> dict[str, Any]:
11
+ post, pub = item["post"], item.get("publication") or {}
12
+ return {
13
+ "post_id": str(post["id"]),
14
+ "title": post.get("title") or "",
15
+ "publication": pub.get("name") or "",
16
+ "publication_id": str(post.get("publication_id") or pub.get("id") or ""),
17
+ "published_at": post.get("post_date"),
18
+ "likes": int(post.get("reaction_count") or 0),
19
+ "url": post.get("canonical_url"),
20
+ }
21
+
22
+
23
+ def note_item(item: dict[str, Any]) -> dict[str, Any]:
24
+ note = item["comment"]
25
+ return {
26
+ "note_id": str(note["id"]),
27
+ "author": person({"id": note.get("user_id"), "name": note.get("name"), "handle": note.get("handle")}),
28
+ "body": note.get("body") or "",
29
+ "published_at": note.get("date"),
30
+ "likes": int(note.get("reaction_count") or 0),
31
+ }