Substack MCP server and CLI for Claude Code and AI agents. 65 tools for drafts, posts, Notes, subscribers, analytics, tags, comments, and researching other writers.
One install gives you both surfaces, the same tools under the same names, covering everything the dashboard does and several things it cannot.
Substack has no public API, which is why your assistant cannot see any of it, and why most things that claim to connect publish posts with the HTML tags showing.
This one speaks Substack's own document format. Ask for a draft and you get a draft, with the YouTube link as a player and the paywall where you put it.
Built and maintained by Navid Moazzez.
substack-cli in your terminal, for scripting, cron, pipes, or a quick question
without opening anything:
substack-cli # every command, one line each
substack-cli list-drafts # what you have in progress
substack-cli get-dashboard-summary # subscribers, revenue, recent posts
substack-cli rank-posts --limit 10 # your best performing posts
substack-cli research-creator-posts --handle someone # study another writer
substack-cli create-draft --title "Draft" --body "..."
substack-cli <command> --help # what any command takes--confirm is the shell spelling of the confirmation that publishing, deleting
and posting Notes require. --json gives JSON, --compact puts it on one line,
and errors are JSON on stderr whichever you pick.
substack-cli schema <command> prints the exact JSON Schema an MCP client
receives for that tool, which is how you can check the two surfaces really are
one thing.
substack-mcp is what Claude Code, Claude Desktop, Cursor and the rest launch.
You never run it by hand:
claude mcp add substack \
-e SUBSTACK_PUBLICATION_URL=example.substack.com \
-e SUBSTACK_SESSION_TOKEN=xxxxx \
-- npx -y @thenavidm/substack-mcp-cliThen just ask: "which post drove the most paid conversions last month?"
| Where you are | What you can reach |
|---|---|
| An agent that can run shell commands, like Claude Code or Cursor | Both. The CLI is the cheaper one: it costs nothing until you type it |
| claude.ai, the Claude Desktop chat tab, or a phone | The server only. There is no shell to run a command in |
| A terminal, a script, cron or CI | The CLI only. There is no MCP client in a shell |
They are the same program reading the same tool definitions, so anything one can do, the other can.
| # | Section | What is in it |
|---|---|---|
| 1 | What you can ask it | Real prompts, not features |
| 2 | Quick install | One line, no account needed |
| 3 | Setup | Getting your session cookie |
| 4 | Connect your client | Claude, Cursor, Windsurf, the rest |
| 5 | Check it worked | And the two things that fail |
| 6 | Which surface, and what each costs | Tokens per turn, and how to spend less |
| 7 | Tools | All 65, grouped by what they reach |
| 8 | Writing safely | What is guarded and what is not |
| 9 | Writing posts | Markdown, embeds, paywalls |
| 10 | Your data | What is stored, and where |
| 11 | Troubleshooting | When something breaks |
| 12 | FAQ | The questions people actually ask |
- Draft this week's post from my notes, in the voice of my last five.
- Which of my posts got the most paid conversions, and what do they have in common?
- How many subscribers have not opened anything in 90 days?
- Pull every Note that mentions pricing from the three writers I compete with.
- Add a paywall after the third section of that draft.
- Schedule this Note for 9am Tuesday.
- Compare my open rate to what it was six months ago.
- Read my inbox and tell me what my corner of Substack is arguing about this week.
- Turn my last three posts into a guide, and put the YouTube version at the top.
The last one is the point. It reads your existing posts, writes a new draft in your format, and embeds the video as a real player rather than a blue link, because it speaks Substack's document format rather than pasting HTML at it.
Node 20 or newer. Nothing else.
npx -y @thenavidm/substack-mcp-cli@latest --versionThat is the whole install. npx fetches it on demand, so there is nothing to update later.
Installing the package needs no account. Only connecting it does, which is the next section.
| You need | Check with | If missing |
|---|---|---|
| Node 20 or newer | node -v |
nodejs.org |
| A Substack publication you own | Open your publication's dashboard | Start one at substack.com, it is free |
| Its canonical address | It ends .substack.com |
Custom domains do not serve the API, see below |
Important
Use the yourname.substack.com address, not a custom domain. Substack does not
serve its API on custom domains: the request redirects and ends in a 404.
Substack has no public API and no OAuth. Everything here runs on your browser session cookie, exactly like the Substack tab you already have open.
Treat that cookie like a password. It is full access to your account. Never paste it into an issue, a gist, or a chat with anyone.
npx @thenavidm/substack-mcp-cli@latest loginIt asks for your publication URL and the cookie, resolves your user id, and stores the result encrypted. Then you can leave the env block out of your client config entirely.
To find the cookie:
- Open your publication and sign in.
- DevTools, then Application, then Cookies.
- Copy the value of
connect.sid. It is long and starts withs%3A.
Turn off ad blockers first. Some of them strip the cookie from that panel.
npx @thenavidm/substack-mcp-cli@latest login --playwriterUses Playwriter to read the cookie out of your running Chrome, where you are already signed in. No browser launch, no sign-in, no CAPTCHA. Requires Playwriter and its extension.
npm i -g playwright && npx playwright install chromium
npx @thenavidm/substack-mcp-cli@latest login --playwrightA browser opens and waits up to ten minutes for you to sign in, CAPTCHA and emailed link included. This is much the slowest option, and the only one that works on a machine with no Chrome, or in CI.
Playwright is not bundled. It is large, it is only ever used by this one command, and the server never loads it, so your tool calls are not slower for it existing.
Nothing above is required. Set SUBSTACK_PUBLICATION_URL and SUBSTACK_SESSION_TOKEN and you are done. SUBSTACK_USER_ID is optional and looked up automatically when absent.
Sessions do expire, commonly reported at around 90 days, though I have not measured it. When calls start failing with an authentication error, run login again, or paste a fresh cookie. doctor warns you once a stored session passes 75 days.
The long version, every step with what to do when one fails, is in INSTALL.md.
Every block below is complete on its own. Pick your client, paste, done.
Replace example.substack.com with your publication and your-connect-sid-value with the cookie from section 3.
claude mcp add substack \
-e SUBSTACK_PUBLICATION_URL=example.substack.com \
-e SUBSTACK_SESSION_TOKEN=your-connect-sid-value \
-- npx -y @thenavidm/substack-mcp-cli@latestRun /mcp inside Claude Code and substack should be listed. Remove it later with claude mcp remove substack.
Open Settings, then Developer, then Edit Config. That reveals claude_desktop_config.json. Or go straight there:
| System | Config file |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
| Linux | ~/.config/Claude/claude_desktop_config.json |
{
"mcpServers": {
"substack": {
"command": "npx",
"args": ["-y", "@thenavidm/substack-mcp-cli@latest"],
"env": {
"SUBSTACK_PUBLICATION_URL": "example.substack.com",
"SUBSTACK_SESSION_TOKEN": "your-connect-sid-value"
}
}
}
}If the file already has other servers, add only the "substack" block inside "mcpServers" and put a comma after the entry before it. One bad comma stops every server loading, not just this one.
Then quit Claude Desktop completely and reopen it. On macOS use Cmd+Q, closing the window is not enough. It only reads that file at startup.
Tip
Claude Desktop does not inherit your shell PATH, so if npx is not found, run
which npx and use that absolute path as command.
~/.cursor/mcp.json for every project, or .cursor/mcp.json inside one. Same JSON as above. Reload the window afterwards.
~/.codeium/windsurf/mcp_config.json. Same JSON. Reload afterwards.
.vscode/mcp.json in a project, or run MCP: Add Server from the command palette.
Zed, Cline, Continue and any other MCP client over stdio all work. They each want the same three things: command, args, and env.
docker run -i --rm \
-e SUBSTACK_PUBLICATION_URL=example.substack.com \
-e SUBSTACK_SESSION_TOKEN=your-connect-sid-value \
ghcr.io/navidmoazzez/substack-mcp:latestOnly one thing needs this: schedule_note publishes from the machine the server runs on, so a Note queued for 9am fires only if that machine is awake. Everything else is fine on a laptop.
substack-mcp --http --port=8788It binds to 127.0.0.1 and serves /health. To reach it from elsewhere set SUBSTACK_MCP_HOST=0.0.0.0 and SUBSTACK_MCP_TOKEN to a random string, and put it behind TLS.
Caution
The HTTP transport holds a live credential for your Substack account. Binding it beyond localhost without a token hands your account to anyone who finds the port.
npx @thenavidm/substack-mcp-cli@latest doctordoctor runs the checks in order and names the actual problem, rather than leaving you to guess which of six things is wrong.
Two things account for almost every failure. Node is not on the PATH your client sees, which the tip above covers. Or the session cookie is wrong or expired, which doctor names directly.
Both surfaces carry the same 65 tools. They differ in when you pay for them.
| Question | MCP server | CLI |
|---|---|---|
| Loaded every turn | ~19,900 tokens | nothing |
| Loaded when Substack comes up | nothing more | ~1,500, once |
| Works on claude.ai and mobile | yes | no, there is no shell there |
| Works in a script, cron or CI | no | yes |
| You invoke it by | asking in plain language | typing a command |
An MCP server sends its whole tool list to the model on every turn, whether you mention Substack or not. That is the price of being connected at all, before you ask anything. It is not unusual, and almost nobody publishes it.
Over twenty turns where Substack comes up once, that is roughly 398,000 tokens against 1,600. When the whole conversation is about your publication, the gap closes and the server is the better experience, because you ask in plain language instead of remembering flags.
Turn the server off when you are not using Substack. In Claude Code that is
@substack to toggle, and every client has an equivalent.
SUBSTACK_READ_ONLY=1 drops it to the 42 reading tools.
Or install the CLI and skip the server. All 65 tools stay reachable, the standing cost falls to roughly a hundred tokens, and you connect the server later on the days it earns its place.
65 tools. Every one declares whether it reads, writes, or does something that cannot be undone, so your client can show you the difference before anything runs.
Every publication-scoped tool takes an optional publication argument to pick which connected Substack it acts on. See Several publications.
| Tool | Risk | What it does |
|---|---|---|
create_draft |
write | Create a draft from markdown. Private until you publish |
update_draft |
write | Change any field. Only what you pass is touched |
get_draft |
read | Read a draft, body returned as markdown you can edit |
list_drafts |
read | Unpublished drafts, most recently edited first |
delete_draft |
destructive | Permanent, no trash. Needs confirm |
publish_draft |
destructive | Publishes and emails your list. Needs confirm |
schedule_draft |
write | Schedule on Substack's side, so it fires without you |
unschedule_draft |
write | Back to a plain draft |
list_scheduled_posts |
read | What is queued, soonest first |
set_draft_body |
write | Replace the body with a document you build node by node |
preview_draft_body |
read | See what a body will render as, changing nothing |
get_sections |
read | Section ids, which create_draft needs |
create_draft takes title, subtitle, body, body_format, section_id, audience (everyone, only_free, only_paid, founding), type (newsletter, podcast, thread), cover_image, and three SEO fields.
get_draft takes body_format of markdown, prosemirror, or both. Markdown is the default, because it is what you can actually edit and send back.
| Tool | Risk | What it does |
|---|---|---|
list_posts |
read | Published posts, newest first |
get_post |
read | Read a post by slug, from any publication |
get_post_by_id |
read | Same, by numeric id |
search_posts |
read | Search your own posts by keyword |
get_post_stats |
read | Opens, clicks, views, signups, reactions for one post |
rank_posts |
read | Rank posts by any metric, to find what worked |
Notes have no draft state on Substack. Writing one publishes it, immediately and publicly.
| Tool | Risk | What it does |
|---|---|---|
publish_note |
destructive | Live and public at once. Needs confirm |
publish_note_with_link |
destructive | With a link preview card. Needs confirm |
schedule_note |
write | Queue one for later. See the caveat below |
list_scheduled_notes |
read | What is queued, published, failed or cancelled |
cancel_scheduled_note |
write | Cancel before it fires |
list_notes |
read | Notes you have published |
delete_note |
destructive | Permanent. Needs confirm |
Substack does not schedule Notes, so the queue is kept locally and this server publishes each one when it comes due.
That only happens while the server is running. A Note set for 9am fires at 9am if your machine is awake with your client open. Otherwise it goes out on the next start after that time, flagged as published late.
Nothing is ever dropped. For scheduling that does not depend on your laptop, see self-hosted over HTTP.
| Tool | Risk | What it does |
|---|---|---|
list_subscribers |
read | Filter on 48 columns with 18 operators |
export_subscribers |
read | The only way to actually read engagement metrics |
get_subscriber_count |
read | Totals, free and paid |
add_subscriber |
write | Add an address to the list |
list_subscribers takes filters as {column, operator, value} combined with AND, plus search, sort_by, sort_direction, limit and offset.
Which operators apply depends on the column's type:
| Type | Operators |
|---|---|
Int |
is is_not gt gte lt lte |
String |
is is_not is_any_of contains starts_with ends_with includes_none |
DateTime |
is_on is_after is_on_or_after is_before is_on_or_before |
Array (tag_ids, emails_enabled) |
includes_any includes_all includes_none |
subscription_type, group_membership |
is is_not is_any_of |
The 48 columns cover identity (name, email, country, state, group), subscription (type, dates, revenue, Stripe plan, attribution), email engagement (opens over 7d/30d/6mo, links clicked, sections) and site engagement (views, comments, shares, days active, activity rating). The full list with types reaches your client in the tool's schema, so the model does not have to guess.
There is no OR and no nesting. That is a limit of Substack's endpoint. Anything needing OR has to be issued as separate calls.
Two things about export_subscribers, both verified against the live API:
tag_idsandgroup_membershipcannot be exported. Substack drops them without failing, so they come back inmissing_columns. Asking for all 48 returns 46.- Values arrive display-formatted. Revenue is
"$50.00"here and the number50throughlist_subscribers.
| Tool | Risk | What it does |
|---|---|---|
get_analytics |
read | One of 16 reports, listed below |
get_dashboard_summary |
read | The headline numbers |
get_email_stats |
read | Delivery, opens, clicks |
get_revenue_summary |
read | Plans, prices, what each brings in |
get_analytics reports: unsubscribes, unsubscribes_timeseries, retention, retention_summary, referrals_leaderboard, referrals_summary, audience_overlap, audience_locations, subscriber_notes, paid_subscriber_growth, arr_timeseries, followers_timeseries, subscribers_timeseries, growth_sources, growth_events, network_attribution.
audience_overlap is the interesting one. It names the other Substacks whose readers overlap yours, with percentages, which is the list of people worth doing a swap with.
| Tool | Risk | What it does |
|---|---|---|
list_publication_tags |
read | Every tag on the publication |
create_tag |
write | Create one |
get_post_tags |
read | Tags on one post |
add_tag_to_post |
write | Tag a post |
remove_tag_from_post |
destructive | Untag. Needs confirm |
get_post_comments |
read | Comments on your post |
comment_on_post |
destructive | Public immediately. Needs confirm |
delete_comment |
destructive | Permanent. Needs confirm |
| Tool | Risk | What it does |
|---|---|---|
list_subscriptions |
read | What this account subscribes to |
list_reader_posts |
read | Your inbox |
get_reader_post |
read | Any post you have access to, including paid ones |
get_reader_feed |
read | The Notes timeline |
get_profile_feed |
read | Everything one account has published |
get_comment_thread |
read | A Note and its replies |
restack_note |
destructive | Republishes to your followers. Needs confirm |
| Tool | Risk | What it does |
|---|---|---|
get_publication_settings |
read | Every setting on the settings page |
update_publication_settings |
write | Change them, including theme colours |
get_user_profile |
read | Which account is connected |
search_publications |
read | Find Substacks by name or topic. No auth needed |
get_publication_info |
read | Public details of any publication |
list_contributors |
read | Who can write on it, with byline ids |
get_import_status |
read | Result of the last subscriber import |
update_publication_settings names accent_color and color_links explicitly, because Substack stores them under opaque theme variable names, and color_links being off is the usual reason links render nearly invisible on a dark theme.
| Tool | Risk | What it does |
|---|---|---|
list_templates |
read | Your saved post templates |
create_template |
write | Save one |
delete_template |
destructive | Permanent. Needs confirm |
create_draft_from_template |
write | Start a draft from one, formatting exact |
| Tool | Risk | What it does |
|---|---|---|
research_creator_posts |
read | Another writer's posts with engagement numbers |
research_creator_notes |
read | Their Notes, ranked by likes or restacks |
compare_publications |
read | Up to 10 publications ranked together |
scrape_post |
read | Any public post from its URL |
compare_publications scores by likes, plus comments times two, plus restacks times three, because a comment and a restack both cost more effort than a like.
| Tool | Risk | What it does |
|---|---|---|
upload_image |
write | Upload from a URL or a local file, get a CDN URL back |
Takes exactly one of url or path. PNG, JPEG, GIF and WebP up to 10MB. The type is checked from the file's contents, not its extension.
Beyond tools, the server exposes two MCP resources (substack://publication and substack://connected) so a client can load your publication's context without spending a tool call, and four prompts: Draft a post from an idea, Find what worked, Study another writer, and Find lapsed subscribers.
One Substack login often owns more than one publication.
"env": {
"SUBSTACK_PUBLICATIONS": "[{\"publication_url\":\"one.substack.com\",\"session_token\":\"...\"},{\"publication_url\":\"two.substack.com\",\"session_token\":\"...\"}]"
}Every publication-scoped tool takes an optional publication argument, matched loosely against the hostname, so "two" finds two.substack.com. Leave it out and the first one is used.
Ask for a publication that is not connected and the error names the ones that are, rather than failing silently against the wrong Substack.
Two positions are common and both are wrong. Ship publish and delete unguarded, and one mis-parsed instruction emails your entire list. Remove them and call that safety, and you have not made anything safer, you have moved the work back to the human.
The actual hazard is narrow and worth naming.
publish_draft with send: true emails every subscriber you have, and there is no unsend. delete_draft has no undo. publish_note and comment_on_post are public the instant they run.
None of these is dangerous when a person meant it. All of them are dangerous one plausible misreading of "tidy up my drafts" away.
So everything works, and the irreversible things need an explicit confirm: true:
delete_draft is irreversible: permanently delete draft 4821.
Nothing has been changed. Re-run with confirm: true if that is what you want.
A careless call trips over that. An intentional one clears it in a single retry.
"env": { "SUBSTACK_READ_ONLY": "1" }Drops the server to its 41 read tools. Write tools are not merely refused, they are not advertised, so the model never tries.
SUBSTACK_ALLOW_DESTRUCTIVE=0 is the middle setting: drafting and tagging still work, publishing and deleting do not.
Every tool sets readOnlyHint, destructiveHint, idempotentHint and openWorldHint explicitly. MCP defaults destructiveHint and openWorldHint to true when omitted, so a read tool left unannotated shows up in a client as dangerous, which trains people to ignore the warnings that matter.
"env": { "SUBSTACK_AUDIT_LOG": "/Users/you/.substack-mcp/audit.log" }Append-only, one JSON line per attempted write, allowed or blocked.
Several tools return text other people wrote. Comments, your reader feed, another writer's posts.
An agent that can read that text and also publish is exposed to instructions hidden inside it. Someone can leave a comment that reads like a command.
Two things push back on that. Every one of those tools says so in its own response, and the server's instructions tell the model to treat that text as data rather than orders.
Neither is complete. The real defence for an agent working unattended is SUBSTACK_READ_ONLY=1, which removes the write tools entirely.
This uses an undocumented API.
Substack publishes no REST API and no OAuth. These are the endpoints its own web app calls.
They can change without notice, and when they do, tools break until the fix ships.
Your session cookie is full account access.
Anyone who gets it can post as you, read your subscribers, and change your billing. It is exactly as sensitive as your password.
Never paste it into an issue.
An agent with publish rights can email your entire list.
The confirmation gate makes that hard to do by accident. It does not make it impossible for a determined bad instruction.
If you are pointing an autonomous agent at this, run it with SUBSTACK_READ_ONLY=1.
Automated subscriber additions are how publications get marked as spam.
add_subscriber exists for people who asked to be added. Importing anyone else is your problem, not Substack's.
Terms of service.
Automating your own account through its own web endpoints is not something Substack documents or blesses.
I am not aware of anyone being banned for it. I cannot promise it, and neither can anyone else shipping a tool like this.
draft_body is not HTML. It is a JSON ProseMirror document. This is the single most common way a Substack integration goes wrong: send HTML and the API returns 200, then the post renders with the tags visible as literal text. There is no error. You find out by looking at the published post.
This server converts markdown, HTML, or a ready-made document into the real format, and detects which you sent.
Headings, bold, italic, inline code, strikethrough, links, images, nested lists to any depth with ordered and unordered mixed, fenced code blocks with a language, blockquotes and horizontal rules.
A line containing only a YouTube, X, Spotify or Vimeo URL becomes a real embedded player:
Here is the walkthrough.
https://www.youtube.com/watch?v=dQw4w9WgXcQ
The transcript is below.Substack's editor does this client-side, so it never happens for anything written through the API. Doing it here is why a draft from this server looks like one you made by hand. Put the URL inside a sentence and it stays an ordinary link.
x.com links are rewritten to twitter.com, because Substack's embed only resolves the latter.
The free part everyone sees.
<paywall>
The part only paying subscribers get.Substack's document format has no table node, so a table cannot be rendered natively. Rather than mangle the pipes into a paragraph, a markdown table is preserved verbatim in a code block. The content survives and you can reformat it in the editor.
get_draft returns markdown by default. Edit one sentence, send it back to update_draft, and the rest of the formatting survives.
preview_draft_body shows exactly what a body will produce, including how many embeds were created and whether the paywall registered, without touching anything.
Nothing is sent anywhere except Substack. There is no telemetry, no analytics, and no third-party service in the path.
Two files, both in ~/.substack-mcp (SUBSTACK_MCP_HOME moves it):
session.json, only if you ran login. Written 0600, encrypted with AES-256-GCM under a key derived from this OS account and this machine, which is never stored.
Be clear about what that buys. A copied file is useless 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 stay fully supported.
scheduled-notes.json, the local queue for schedule_note. Plain JSON, 0600, containing the text of Notes you have not published yet.
Your posts, drafts and subscribers are never copied locally. Every read goes to Substack live.
your MCP client
| stdio (or HTTP)
v
substack-mcp
|
+-- write guard confirm, read-only, audit log
+-- content pipeline markdown/HTML <-> ProseMirror, embeds
+-- one HTTP client timeout, retry, backoff, spacing
|
v
Substack's own JSON endpoints, signed with your session cookie
Every request goes through one client, so an upstream change is fixed in one file. That client adds four things a bare fetch does not:
A real deadline. Node applies no request timeout, only a 10 second connect timeout. A host that accepts the connection and then goes quiet would otherwise hang a tool call forever. Default 30 seconds, set with SUBSTACK_REQUEST_TIMEOUT_MS.
Retries that help. 429 and 5xx get exponential backoff with jitter, honouring Retry-After. Nothing else is retried, because nothing else resolves by waiting.
Request spacing. A floor of 350ms between requests, serialised through a queue, so a model looping over 200 posts does not get your account rate limited. SUBSTACK_MIN_REQUEST_INTERVAL_MS.
A browser identity. The client sends a browser User-Agent, Referer and Origin, because some publications sit behind Cloudflare, which blocks unrecognized clients. The error mapper recognises a Cloudflare block hiding inside a 403 and tells you to use the canonical host.
Errors map to typed classes, so the message names the fix rather than saying "Substack API error":
| Class | Status | Cause |
|---|---|---|
AuthenticationError |
401/403 | Expired session, or a Cloudflare block |
RateLimitError |
429 | Too many requests, after retries |
ValidationError |
400 | Bad arguments |
NotFoundError |
404 | No such draft, post or note |
ServerError |
5xx | Substack's problem |
TimeoutError |
408 | Our own deadline, no response arrived |
Run npx @thenavidm/substack-mcp-cli@latest doctor first. It checks credentials, config, connectivity and byline resolution, and names what is wrong.
"Substack rejected the session" Your cookie expired. Get a fresh connect.sid, or run login again.
Calls fail on a custom domain A publication served on its own domain does not answer the API there. The request redirects and ends in a 404. Set SUBSTACK_PUBLICATION_URL to the canonical *.substack.com host instead, which is served directly. The research tools retry the canonical host automatically; everything else needs it configured.
Some custom domains sit behind Cloudflare, which can answer 403 with error code: 1010. That is the same fix.
Post renders with visible HTML tags You are on an older version, or something else wrote that draft. This server never sends HTML as draft_body. Check with preview_draft_body.
create_draft fails on the byline Substack requires a byline, and it is your numeric user id. Normally resolved automatically. If that fails, set SUBSTACK_USER_ID. doctor tells you which.
A scheduled Note did not fire The server was not running at the time. It publishes on the next start, marked published_late. Check with list_scheduled_notes, and see the next section.
Tools missing from the list SUBSTACK_READ_ONLY is set. The 24 write tools are hidden by design.
Nothing happens at all Check your client's MCP logs. On a bad config the server still starts and reports the problem per tool call, rather than failing silently at boot.
| Variable | Default | What it does |
|---|---|---|
SUBSTACK_PUBLICATION_URL |
Your publication, e.g. example.substack.com |
|
SUBSTACK_SESSION_TOKEN |
The connect.sid cookie value |
|
SUBSTACK_USER_ID |
resolved | Your numeric user id |
SUBSTACK_PUBLICATIONS |
JSON array, for several publications | |
SUBSTACK_READ_ONLY |
0 |
Disable every write |
SUBSTACK_ALLOW_DESTRUCTIVE |
1 |
Allow publish and delete |
SUBSTACK_AUDIT_LOG |
Append-only log of attempted writes | |
SUBSTACK_REQUEST_TIMEOUT_MS |
30000 |
Per-request deadline |
SUBSTACK_MIN_REQUEST_INTERVAL_MS |
350 |
Minimum spacing between requests |
SUBSTACK_MAX_RETRIES |
3 |
Retries on 429 and 5xx |
SUBSTACK_USER_AGENT |
Chrome | Override the browser signature |
SUBSTACK_MCP_HOME |
~/.substack-mcp |
Where session and queue live |
SUBSTACK_MCP_HOST |
127.0.0.1 |
HTTP bind address |
SUBSTACK_MCP_PORT |
8788 |
HTTP port |
SUBSTACK_MCP_TOKEN |
Bearer token for HTTP | |
SUBSTACK_MCP_ALLOWED_ORIGINS |
Extra origins beyond localhost |
| Variable | Default | What it does |
|---|---|---|
SUBSTACK_PUBLICATION_URL |
Your publication, e.g. example.substack.com |
|
SUBSTACK_SESSION_TOKEN |
The connect.sid cookie value |
|
SUBSTACK_USER_ID |
resolved | Your numeric user id |
SUBSTACK_PUBLICATIONS |
JSON array, for several publications | |
SUBSTACK_READ_ONLY |
0 |
Disable every write |
SUBSTACK_ALLOW_DESTRUCTIVE |
1 |
Allow publish and delete |
SUBSTACK_AUDIT_LOG |
Append-only log of attempted writes | |
SUBSTACK_REQUEST_TIMEOUT_MS |
30000 |
Per-request deadline |
SUBSTACK_MIN_REQUEST_INTERVAL_MS |
350 |
Minimum spacing between requests |
SUBSTACK_MAX_RETRIES |
3 |
Retries on 429 and 5xx |
SUBSTACK_USER_AGENT |
Chrome | Override the browser signature |
SUBSTACK_MCP_HOME |
~/.substack-mcp |
Where session and queue live |
SUBSTACK_MCP_HOST |
127.0.0.1 |
HTTP bind address |
SUBSTACK_MCP_PORT |
8788 |
HTTP port |
SUBSTACK_MCP_TOKEN |
Bearer token for HTTP | |
SUBSTACK_MCP_ALLOWED_ORIGINS |
Extra origins beyond localhost |
Two are required. Everything else has a working default and exists so you can tighten or tune it.
Credentials
| Variable | What it is |
|---|---|
SUBSTACK_PUBLICATION_URL |
Your publication, e.g. example.substack.com |
SUBSTACK_SESSION_TOKEN |
The connect.sid cookie value. Section 3 shows where to find it |
SUBSTACK_USER_ID |
Optional. Resolved automatically when absent |
SUBSTACK_PUBLICATIONS |
A JSON array instead, for several publications at once |
Safety
| Variable | Default | What it does |
|---|---|---|
SUBSTACK_READ_ONLY |
0 |
1 hides every write, leaving the 42 reading tools |
SUBSTACK_ALLOW_DESTRUCTIVE |
1 |
0 keeps ordinary writes, blocks publishing and deleting |
SUBSTACK_AUDIT_LOG |
none | Path to an append-only log of every attempted write |
Tuning
| Variable | Default | What it does |
|---|---|---|
SUBSTACK_REQUEST_TIMEOUT_MS |
30000 |
Per-request deadline |
SUBSTACK_MIN_REQUEST_INTERVAL_MS |
350 |
Spacing between requests |
SUBSTACK_MAX_RETRIES |
3 |
Retries on rate limits and 5xx |
SUBSTACK_USER_AGENT |
a browser UA | Sent on every request |
SUBSTACK_MCP_HOME |
~/.substack-mcp |
Where the session and queued Notes are kept |
Serving over HTTP (--http, see SECURITY.md before you use it)
| Variable | Default | What it does |
|---|---|---|
SUBSTACK_MCP_PORT |
8788 |
Port to bind |
SUBSTACK_MCP_HOST |
127.0.0.1 |
Interface to bind |
SUBSTACK_MCP_TOKEN |
none | Bearer token. Required in practice if you bind beyond localhost |
SUBSTACK_MCP_ALLOWED_ORIGINS |
none | Comma-separated origins allowed to connect |
See CHANGELOG.md.
What is an MCP server?
An MCP server is a standard way to give an AI assistant real access to a tool.
Instead of describing your Substack to Claude and hoping it guesses right, the server exposes your actual drafts, subscribers and analytics as things the assistant can read and act on.
MCP is the protocol they agree on, so one server works in Claude, Cursor, Windsurf and anything else that speaks it.
What is Substack?
Substack is a publishing platform for newsletters and blogs. Writers publish posts that go out by email and live on the web, sell paid subscriptions, and post short updates called Notes. This server connects an AI assistant to a Substack publication you own.
Do I need to be technical to use this?
You need to be able to paste a line into a terminal and copy a value out of your browser. That is the whole skill requirement. Section 3 walks through the browser part click by click, and doctor tells you what is wrong in plain language if something does not work.
Is my data sent anywhere? Who can see it?
Nothing goes anywhere except Substack. There is no backend, no telemetry, and no third party in the path. Your session cookie and any queued Notes sit in ~/.substack-mcp on your own machine, and section 10 says exactly what is in each file.
What can it do that I cannot do in the Substack dashboard already?
Three things the dashboard cannot do.
It reads the engagement metrics Substack lets you filter on but never shows you, through export_subscribers.
It pulls another writer's posts and Notes with their like and restack counts, so you can rank by what actually worked.
And it turns markdown into real Substack formatting, including embeds and paywalls, which the editor does not do for anything written outside it.
Can it delete something by accident?
Not without being told twice. delete_draft, delete_note, delete_comment and delete_template are permanent, with no trash to recover from.
All four refuse to run unless the call passes confirm: true. The same guard covers publish_draft, because publishing with send: true emails your whole list and an email cannot be unsent.
Setting SUBSTACK_READ_ONLY=1 removes all 24 write tools from the list entirely.
Does it cost anything?
It costs nothing. The server is MIT licensed and free, and Substack itself is free to write on. Some analytics reports only return data once your publication has paid subscribers, because there is nothing to report until then.
Does it work with ChatGPT or Cursor, or only Claude?
It works with any client that speaks MCP. Claude Code, Claude Desktop, Cursor, Windsurf, VS Code, Zed and Cline are all covered in section 2, and anything else that supports MCP over stdio will work with the same three settings.
Can I connect more than one publication?
You can connect as many as you like. One Substack login often owns several, so every publication-scoped tool takes an optional publication argument matched against the hostname. Set SUBSTACK_PUBLICATIONS to a JSON array and pass publication: "example" to pick one, or leave it out and the first is used.
What happens when my session expires?
Substack sessions do expire, and when yours does every authenticated tool starts returning an authentication error naming the cause. The fix is to grab a fresh connect.sid cookie and update it, or run substack-mcp login again. doctor warns you once a stored session passes 75 days, before it breaks.
How do I disconnect it?
Remove the server from your client's config, which for Claude Code is claude mcp remove substack. Then delete ~/.substack-mcp to remove the stored session and any queued Notes. Nothing is left behind, and nothing was ever stored anywhere but your own machine.
Run into a problem or have a question? Open an issue and I will help.
Found a security vulnerability? Report it privately instead, never as an issue. SECURITY.md covers what this holds, the write-safety model, and running it over HTTP.
Navid Moazzez is a leading AI business strategist, and the host of the AI Creator Summit, watched by 100,000+ creators. He helps creators and founders master AI and build their own AI Operating System (AI OS) to automate their business and life. This Substack MCP server is one piece of that system.
Links
- Personal website: navid.me
- Link in bio: navid.bio
- Navid Media: navid.media
- YouTube: @thenavidm and @thenavidai
- X: @thenavidm
- Instagram: @thenavidm
- LinkedIn: thenavidm
If this is useful, star the repo and come say hi on X.
| Library | License | What it does |
|---|---|---|
| MCP TypeScript SDK | MIT | The MCP server and transports |
| zod | MIT | Tool argument schemas and validation |
Playwright is an optional peer dependency, used only by login --playwright and never loaded by the server.
MIT. Free to use, modify, and share.
Not affiliated with, endorsed by, or connected to Substack Inc.
Β© 2026 NM Media. Made with β€οΈ by Navid Moazzez.
