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