Report it privately. Please do not open a public issue for a security problem: an issue is visible to everyone the moment you file it, including whoever would use the bug.
Include what you did, what happened, and what you expected. A proof of concept helps. Reporters are credited in the fix notes unless they would rather not be.
Your Substack session cookie, in SUBSTACK_SESSION_TOKEN. Substack has no
public API and no OAuth, so there is no scoped credential to issue. This is the
same cookie your browser holds, and it is full access to your account: posting,
your subscriber list, your billing.
Treat it exactly as you would your password. Never paste it into an issue, a gist, or a chat. If one leaks, sign out of all sessions from Substack's settings, which invalidates it, then capture a fresh one.
A stored session and an audit log, in ~/.substack-mcp unless
SUBSTACK_MCP_HOME moves them. substack-cli login writes session.json at
0600, encrypted with AES-256-GCM under a key derived from this machine and OS
account, which is never stored.
Be clear on what that buys. A copied file will not decrypt elsewhere, and a casual disk or backup read sees ciphertext. It is machine binding, not a vault: code running as you on this machine can re-derive the key. That is the same exposure as the environment variable path, which is why environment variables remain fully supported.
scheduled-notes.json sits in the same directory and holds the text of Notes
you have queued but not published.
Nothing leaves your machine except calls to Substack. No telemetry.
Writes work by default, because publishing is the point of the server. A server where every write needs a flag teaches the operator to set that flag permanently, which is worse than no protection because it looks like protection.
Three graduated mechanisms instead:
Approval for the operations that cannot be taken back. Publishing a
draft with send: true emails every subscriber you have, and an email cannot be
unsent. Deleting a draft, a Note, a comment or a template is permanent with no
trash to recover from. Publishing a Note, commenting and restacking are public
the instant they run, with no draft state in between.
Over MCP a person approves each of these where the client can ask: Claude Code
shows its own prompt, and a client that can show forms asks with one. Each
approval is signed, bound to that exact call and works once. Where a client can
do neither, the model must pass confirm: true, and SUBSTACK_CONFIRM=model
allows that everywhere, for an agent with no person to ask. In a terminal it is
--confirm, which --agent never adds.
Creating and editing drafts, tagging, scheduling and adding a subscriber are not guarded. Each is private or reversible, and approving everything trains people and models to approve reflexively, which is worse than not asking.
SUBSTACK_READ_ONLY=1 removes every write from the tool list. Not a refusal
at call time: the tools are never registered, leaving 41 read tools. A model
cannot call a tool it cannot see, and cannot argue with a refusal it never
receives. This is the setting for pointing an untrusted agent at a publication.
SUBSTACK_ALLOW_DESTRUCTIVE=0 sits between the two: drafting and tagging
keep working, publishing and deleting do not.
SUBSTACK_AUDIT_LOG=<path> records every attempted write, allowed and
blocked alike, one JSON line each, with who approved it. The model has no tool
to read or edit that file.
Comments, your reader feed, another writer's posts and Notes, and anything the research tools return are all text other people wrote. "Summarize my comments" is one of the first things anyone asks.
Treat that content as data to report on, never as instructions. Every tool that returns it says so in its own response, and the server's instructions tell the model the same. The risk is highest when writes are enabled, because a comment is a text field aimed at an agent that can publish.
upload_image takes a URL and fetches it. Private, loopback and link-local
destinations are refused, and redirects are followed manually so the check
applies to every hop, so it cannot be turned into a proxy into a private
network. Files are capped at 10MB and the type is read from the file's contents
rather than its extension.
The URL it returns is unlisted, not secret. Anyone holding it can fetch the image, including before the post is published.
The HTTP transport refuses browser requests from other sites unless
SUBSTACK_HTTP_ALLOWED_ORIGINS lists them, and takes a bearer token in
SUBSTACK_HTTP_TOKEN, but that is a lock on one door, not an authentication
system. It belongs behind TLS and an authenticating reverse proxy.
Do not expose it directly. It holds a live credential for your Substack account, and an open endpoint hands it to anyone who finds the port. It will not start on any address but localhost without a token, for exactly this reason.
Read, run and pull apart anything here. Nobody but the maintainer can change this repository, so nothing you do while investigating puts it at risk.
The care is owed to the service the tool talks to, not to the code. When testing, use your own account and your own data. Do not point it at somebody else's, and do not hammer a shared API to the point where other people notice. If a test could affect anyone but you, stop and send a private report first.
Research done in that spirit is welcome, and nothing here is a trap.
The latest published version gets fixes. Given the size of this project, older versions do not.