Lets an AI client create and edit Substack drafts, publish posts, query subscribers, and upload images through a live session.
Also known as marcomoauro/substack-mcp, substack-mcp
- Stars
- 94
- Tools
- 55
- Downloads, 30 days
- —
- Latest version
- 1.3.2
- Last commit
- Sep 3, 2026
- License
- MIT
What is Substack MCP?
Substack lets an AI client create and edit Substack drafts, publish posts, query subscribers, and upload images through a live session. It exposes 55 tools, such as title, subtitle, body.
Summary generated from the project's README
About Substack MCP server
Managing a Substack publication through an AI client is possible with this MCP server, which connects to your account using an authenticated web session. It provides 27 tools that allow you to automate content creation, manage your reader base, and handle media. You can create new drafts, publish posts, or update existing content using structured documents that include headings, lists, and paywalls.
To set this up, you must provide your publication URL, a session token from your browser's network headers, and your numeric user ID. The server runs locally via Node.js or through a Docker container. Because it relies on your active session rather than a public API, you must treat your session token with the same security as a password.
Beyond writing, the server enables deep interaction with your audience. You can list or filter subscribers using various operators, export detailed engagement metrics like post views and email opens, and upload images to Substack's own hosting to ensure they render correctly in your posts.
What you can do with it
- 1Format complex newsletter contentTransform a plain text draft into a structured document containing headings, code blocks, and paywalls.
- 2Analyze reader engagementExport a complete list of subscribers including their specific email open rates and post view counts.
- 3Prepare images for publicationUpload an image from a local file path so it can be properly hosted and displayed within a Substack post.
Written from the project's README. Every use case is checked against a quote from it.
How to install Substack MCP
Formats verified 10 Nov 2026Add to CursorAdd to GooseInstall Substack MCP in Claude Code
$ claude mcp add --env 'SUBSTACK_SESSION_TOKEN=<YOUR_SUBSTACK_SESSION_TOKEN>' --env 'SUBSTACK_PUBLICATION_URL=<SUBSTACK_PUBLICATION_URL>' --env 'SUBSTACK_USER_ID=<SUBSTACK_USER_ID>' substack-mcp -- npx -y substack-mcpInstall Substack MCP in Cursor
~/.cursor/mcp.json
{
"mcpServers": {
"substack-mcp": {
"command": "npx",
"args": [
"-y",
"substack-mcp"
],
"env": {
"SUBSTACK_SESSION_TOKEN": "<YOUR_SUBSTACK_SESSION_TOKEN>",
"SUBSTACK_PUBLICATION_URL": "<SUBSTACK_PUBLICATION_URL>",
"SUBSTACK_USER_ID": "<SUBSTACK_USER_ID>"
}
}
}
}Install Substack MCP in VS Code
.vscode/mcp.json
{
"servers": {
"substack-mcp": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"substack-mcp"
],
"env": {
"SUBSTACK_SESSION_TOKEN": "<YOUR_SUBSTACK_SESSION_TOKEN>",
"SUBSTACK_PUBLICATION_URL": "<SUBSTACK_PUBLICATION_URL>",
"SUBSTACK_USER_ID": "<SUBSTACK_USER_ID>"
}
}
}
}Install Substack MCP in VS Code CLI
$ code --add-mcp '{"name":"substack-mcp","type":"stdio","command":"npx","args":["-y","substack-mcp"],"env":{"SUBSTACK_SESSION_TOKEN":"<YOUR_SUBSTACK_SESSION_TOKEN>","SUBSTACK_PUBLICATION_URL":"<SUBSTACK_PUBLICATION_URL>","SUBSTACK_USER_ID":"<SUBSTACK_USER_ID>"}}'Install Substack MCP in Claude Desktop
claude_desktop_config.json
{
"mcpServers": {
"substack-mcp": {
"command": "npx",
"args": [
"-y",
"substack-mcp"
],
"env": {
"SUBSTACK_SESSION_TOKEN": "<YOUR_SUBSTACK_SESSION_TOKEN>",
"SUBSTACK_PUBLICATION_URL": "<SUBSTACK_PUBLICATION_URL>",
"SUBSTACK_USER_ID": "<SUBSTACK_USER_ID>"
}
}
}
}Install Substack MCP in Codex
$ codex mcp add substack-mcp --env 'SUBSTACK_SESSION_TOKEN=<YOUR_SUBSTACK_SESSION_TOKEN>' --env 'SUBSTACK_SESSION_TOKEN=<YOUR_SUBSTACK_SESSION_TOKEN>' --env 'SUBSTACK_PUBLICATION_URL=<SUBSTACK_PUBLICATION_URL>' --env 'SUBSTACK_PUBLICATION_URL=<SUBSTACK_PUBLICATION_URL>' --env 'SUBSTACK_USER_ID=<SUBSTACK_USER_ID>' --env 'SUBSTACK_USER_ID=<SUBSTACK_USER_ID>' -- npx -y substack-mcpInstall Substack MCP in Codex config.toml
~/.codex/config.toml
[mcp_servers.substack-mcp] command = "npx" args = ["-y", "substack-mcp"] [mcp_servers.substack-mcp.env] SUBSTACK_SESSION_TOKEN = "<YOUR_SUBSTACK_SESSION_TOKEN>" SUBSTACK_PUBLICATION_URL = "<SUBSTACK_PUBLICATION_URL>" SUBSTACK_USER_ID = "<SUBSTACK_USER_ID>"
Install Substack MCP in Gemini CLI
$ gemini mcp add -s user -e 'SUBSTACK_SESSION_TOKEN=<YOUR_SUBSTACK_SESSION_TOKEN>' -e 'SUBSTACK_SESSION_TOKEN=<YOUR_SUBSTACK_SESSION_TOKEN>' -e 'SUBSTACK_PUBLICATION_URL=<SUBSTACK_PUBLICATION_URL>' -e 'SUBSTACK_PUBLICATION_URL=<SUBSTACK_PUBLICATION_URL>' -e 'SUBSTACK_USER_ID=<SUBSTACK_USER_ID>' -e 'SUBSTACK_USER_ID=<SUBSTACK_USER_ID>' substack-mcp npx -- -y substack-mcpInstall Substack MCP in Gemini settings.json
~/.gemini/settings.json
{
"mcpServers": {
"substack-mcp": {
"command": "npx",
"args": [
"-y",
"substack-mcp"
],
"env": {
"SUBSTACK_SESSION_TOKEN": "<YOUR_SUBSTACK_SESSION_TOKEN>",
"SUBSTACK_PUBLICATION_URL": "<SUBSTACK_PUBLICATION_URL>",
"SUBSTACK_USER_ID": "<SUBSTACK_USER_ID>"
}
}
}
}Substack MCP tools 55
Detected from the READMEtitlesubtitlebodyfilterssearchsort_bysort_directionlimitoffsetcolumnsmax_wait_secondsstatus
| title | — | |
| subtitle | — | |
| body | — | |
| filters | — | |
| search | — | |
| sort_by | — | |
| sort_direction | — | |
| limit | — | |
| offset | — | |
| columns | — | |
| max_wait_seconds | — | |
| status | — |
Show all 55 toolsShow fewer tools
draft_idurlpathpost_iddraft_titledraft_subtitleaudiencewrite_comment_permissionsdefault_comment_sortcover_imagesocial_titledescriptionsearch_engine_titlesearch_engine_descriptionslugsendfullinclude_hiddentag_namecreate_if_missingactive_onlyafterinclude_bodytabcursorinclude_tabsuser_idtypecomment_idinclude_repliestab_idorder_byorder_directionreportretentionretention_summarygrowth_sourcesgrowth_eventsaudience_overlapaudience_locationssubscriber_notespaid_subscriber_growthnetwork_attribution
| draft_id | — | |
| url | — | |
| path | — | |
| post_id | — | |
| draft_title | — | |
| draft_subtitle | — | |
| audience | — | |
| write_comment_permissions | — | |
| default_comment_sort | — | |
| cover_image | — | |
| social_title | — | |
| description | — | |
| search_engine_title | — | |
| search_engine_description | — | |
| slug | — | |
| send | — | |
| full | — | |
| include_hidden | — | |
| tag_name | — | |
| create_if_missing | — | |
| active_only | — | |
| after | — | |
| include_body | — | |
| tab | — | |
| cursor | — | |
| include_tabs | — | |
| user_id | — | |
| type | — | |
| comment_id | — | |
| include_replies | — | |
| tab_id | — | |
| order_by | — | |
| order_direction | — | |
| report | — | |
| retention | — | |
| retention_summary | — | |
| growth_sources | — | |
| growth_events | — | |
| audience_overlap | — | |
| audience_locations | — | |
| subscriber_notes | — | |
| paid_subscriber_growth | — | |
| network_attribution | — |
Configuration
SUBSTACK_SESSION_TOKENRequiredSecretValue of the substack.sid or connect.sid cookie from your authenticated Substack session
SUBSTACK_SESSION_TOKENRequiredSecretValue of the substack.sid or connect.sid cookie from your authenticated Substack session
SUBSTACK_PUBLICATION_URLRequiredFull URL of your Substack publication, including https://
SUBSTACK_PUBLICATION_URLRequiredFull URL of your Substack publication, including https://
SUBSTACK_USER_IDRequiredNumeric user ID returned by Substack's publication_user request
SUBSTACK_USER_IDRequiredNumeric user ID returned by Substack's publication_user request
SUBSTACK_PUBLICATION_URLSUBSTACK_SESSION_TOKENSUBSTACK_USER_ID
| Name | Description | |
|---|---|---|
| SUBSTACK_SESSION_TOKEN | Value of the substack.sid or connect.sid cookie from your authenticated Substack session | RequiredSecret |
| SUBSTACK_SESSION_TOKEN | Value of the substack.sid or connect.sid cookie from your authenticated Substack session | RequiredSecret |
| SUBSTACK_PUBLICATION_URL | Full URL of your Substack publication, including https:// | Required |
| SUBSTACK_PUBLICATION_URL | Full URL of your Substack publication, including https:// | Required |
| SUBSTACK_USER_ID | Numeric user ID returned by Substack's publication_user request | Required |
| SUBSTACK_USER_ID | Numeric user ID returned by Substack's publication_user request | Required |
| SUBSTACK_PUBLICATION_URL | — | |
| SUBSTACK_SESSION_TOKEN | — | |
| SUBSTACK_USER_ID | — |
Automated checks
Ran Nov 12, 2026Full report →Passed our automated checks. That is not a security audit: read the code and give the server the narrowest access it needs. How checks work
- Source repository linked and reachablePass
- Open source license (OSI)Pass
- Commit or release in the last yearPass
- Package name not close to a popular packagePass
- Remote endpoint uses HTTPSNot checked
- Destructive tools are labelledPass
- No hidden instructions to the modelPass
- No critical advisory in the package or its dependenciesPass
README
View on GitHubSubstack MCP Server
A Model Context Protocol (MCP) Server for Substack enabling LLM clients to interact with Substack's API for automations like creating posts, managing drafts, and more.
Create and publish posts, work with subscribers and analytics, browse your reader feeds, manage tags and comments, and upload images — 27 tools exposed through one MCP server.
[!IMPORTANT] Substack does not provide a public API for these operations. This server uses your authenticated web session. Treat the session token exactly like a password: keep it local, never commit it, and never include it or a complete Cookie header in a bug report.
Quick start
The fastest installation uses Node.js 22 or newer and npx.
1. Collect your Substack credentials
Sign in to Substack in your browser and open your publication dashboard. You need three values:
- Publication URL — the full base URL of your publication, for example
https://your-publication.substack.com. - Session token — open your browser's developer tools, select Network, filter to
Fetch/XHR, and reload the dashboard. Open a successful authenticated request to your
publication. Under Request Headers, find the
Cookieheader and locate a session cookie namedsubstack.sidorconnect.sid. Copy its value without the cookie name or the rest of the header. If both names appear with different values, test them separately and locally with the read-only verification in step 3; never paste either value into an issue. - User ID — in the same Network panel, search for a successful
publication_userrequest. In its JSON response, copy the numericidinside theuserobject.
If the browser UI differs, the illustrated credential guide shows the same requests. If authentication later stops working, sign in again and repeat these steps to obtain the current token.
2. Add the server to your MCP client
For clients that accept MCP JSON configuration, add:
{
"mcpServers": {
"substack": {
"command": "npx",
"args": ["-y", "substack-mcp@latest"],
"env": {
"SUBSTACK_PUBLICATION_URL": "https://your-publication.substack.com",
"SUBSTACK_SESSION_TOKEN": "your-session-token",
"SUBSTACK_USER_ID": "your-user-id"
}
}
}
}
Replace the three example values, save the configuration, and restart your MCP client. Consult your client's documentation if it uses a different configuration format.
3. Verify the connection
Ask your client:
List my five most recent Substack drafts.
The client should call list_posts with status: "drafts". If it fails, check the client's MCP
logs and the logging section below before opening an issue.
Docker quick start
To use the published Docker image instead of Node.js, add this server configuration:
{
"mcpServers": {
"substack": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "SUBSTACK_PUBLICATION_URL",
"-e", "SUBSTACK_SESSION_TOKEN",
"-e", "SUBSTACK_USER_ID",
"marcomoauro/substack-mcp:latest"
],
"env": {
"SUBSTACK_PUBLICATION_URL": "https://your-publication.substack.com",
"SUBSTACK_SESSION_TOKEN": "your-session-token",
"SUBSTACK_USER_ID": "your-user-id"
}
}
}
}
🛠 Available Tools
Inputs:
title(string): Title of the postsubtitle(string): Subtitle of the postbody(string): Body of the post. Plain text becomes one paragraph per line — Markdown is not interpreted, so## Headingarrives literally. A JSON string of a Substack document also works and is validated against the same schemaset_post_bodypublishes, so an unrecognised node name is an error rather than a silently mangled post.
Returns: {draft_id, is_published}. Pass draft_id to get_draft to read the draft back.
For anything structured — headings, lists, links, code, images, a paywall — use set_post_body
after creating the draft: the schema is published there, so the calling model can read the node
vocabulary rather than guess at it.
Exposes the same filtering the Subscribers dashboard offers: 48 columns, 18 operators, free-text search, sorting and pagination.
Inputs:
filters(array, optional): conditions combined with AND, each{column, operator, value}search(string, optional): free text matched against subscriber name and emailsort_by(string, optional): any filterable columnsort_direction(asc|desc, optional): defaults todesclimit(number, optional): 1–100, defaults to 25offset(number, optional): for paging
Which operators a column accepts depends on its 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 columns cover subscriber identity (name, email, country, state, group membership), subscription (type, start/expiry/cancel dates, revenue, Stripe plan, attribution), email engagement (opens and unique opens over 7d/30d/6mo, links clicked, sections) and site engagement (post views, unique posts seen, comments, shares, days active, activity rating). The full list with types reaches the client in the tool's JSON Schema, so a model does not have to guess names.
Returns: {count, returned, limit, offset, subscribers}. count is the total matching the
filters regardless of limit, so a call with limit: 1 is a cheap way to size a segment.
Note: engagement columns can be filtered on here but are not part of the records this tool returns — Substack takes the fields it returns from the publication's saved Display settings and ignores a per-request column list. Use
export_subscribersto read their values.
There is no OR and no nesting: anything needing OR has to be issued as separate calls.
The way to actually read the engagement metrics list_subscribers can only filter on: email opens
over 7d/30d/6mo, unique emails seen, post views, unique posts seen, comments, shares, links clicked,
days active and activity rating.
Inputs:
filters(array, optional): the same conditions aslist_subscribers, combined with ANDsearch(string, optional): free text matched against subscriber name and emailcolumns(array, optional): which columns to include, defaulting to all of themmax_wait_seconds(number, optional): 1–600, defaulting to 120
Returns: {count, columns, missing_columns, unmapped_columns, export_id, subscribers}, where
each subscriber is keyed by column name.
Substack generates the file asynchronously, so the tool creates a subscriber set, requests the
export, polls until it is ready and downloads it. A small export lands in a few seconds. If the wait
budget runs out the tool says so and names the export_id rather than blocking.
Two caveats, both verified against the live API:
tag_idsandgroup_membershipcannot be exported. Substack drops them silently rather than failing, so they are reported inmissing_columns— asking for all 48 columns returns 46.- Values arrive display-formatted, not raw: revenue is
"€50.00"here and the number50throughlist_subscribers. Dates are ISO strings.
There is no paging: an export covers the whole matching set.
Inputs:
status(drafts|published|scheduled): which list to readsearch(string, optional): free text matched against title and contentlimit(number, optional): 1–100, defaults to 25offset(number, optional): for pagingsort_direction(asc|desc, optional): drafts and published posts are newest-first, scheduled posts soonest-first
Returns: {status, total, returned, limit, offset, posts}, each post summarised — use
get_draft for the full content of an unpublished one.
Inputs:
draft_id(number): the id returned bylist_postsorcreate_draft_post
Returns: the draft as Substack stores it, body and audience/email settings included.
The only way to write structured content: headings, lists, links, code blocks, quotes, images,
buttons and a paywall. create_draft_post takes plain text; this takes the document Substack
actually stores, and its schema is published in tools/list so the calling model can read the node
vocabulary instead of guessing.
Inputs:
draft_id(number): the id returned bylist_postsorcreate_draft_postbody(object): a Substack ProseMirror document —{type: 'doc', content: [...]}
Fifteen node types are accepted: paragraph, heading, bullet_list, ordered_list, list_item,
blockquote, highlighted_code_block, code_block, horizontal_rule, captionedImage, button,
paywall, youtube2, plus digestPostEmbed, substack_mentions and directMessage passed through
unchanged so a document read with get_draft can be written back. Marks: strong, em, code,
strikethrough, link.
Returns: {draft_id, nodes}, where nodes counts what was stored by type — so a caller that
asked for a paywall can confirm there is one. Validation cannot report a node that was never sent.
Three things worth knowing:
- An image must already be hosted by Substack.
image2.srcpointing at an external URL is stored but does not render. Useupload_imageto re-host one and get asrcthat works. - A document may contain at most one
paywall. Substack accepts two and renders both, leaving it undefined which one cuts the post, so this tool refuses the second. ordered_listnumbers fromattrs.order, notattrs.start. A list given onlystartrenders from 1 with no error.
Substack's editor uploads images as base64 data URIs to POST /api/v1/image, which answers with a
Substack-hosted URL. image2.src in set_post_body and cover_image in update_draft only render
such a URL, so this tool is the bridge. Substack itself only re-fetches URLs already in its own
storage, so the image is encoded here rather than being handed off.
Inputs — exactly one of url or path:
url(string): the http(s) URL of an image to download and re-hostpath(string): absolute path to an image file on the machine running this server, read straight from disk with no downloadpost_id(number, optional): the post the image belongs to; its effect is unconfirmed
Returns: {id, url, content_type, bytes, width, height} — put url into an image2.src when
calling set_post_body, or into cover_image when calling update_draft.
A download is guarded: only http/https, private and loopback hosts are refused after DNS
resolution (redirects are re-checked at every hop), the content type must be an image, HEIC is
rejected with a note to convert it, and the image may not exceed 10 MB.
A local file is guarded differently, because it has no Content-Type header to trust. The path
must be absolute — a relative one would resolve against this server's working directory, not the
calling client's — and the type is read from the file's magic bytes rather than its extension, so a
non-image with an image extension is caught here instead of at Substack. PNG, JPEG, GIF and WebP are
accepted; HEIC and SVG are not. The same 10 MB cap applies, checked against the file size before the
file is read. Note that path reads whatever absolute path it is given: if that matters in your
setup, do not expose this server to a client you would not trust with your filesystem.
The update is partial: only the fields you pass change, and the body is left alone.
Inputs:
draft_id(number): the id returned bylist_postsorcreate_draft_postdraft_title(string, optional)draft_subtitle(string, optional)audience(everyone|only_paid|only_free|founding, optional)write_comment_permissions(everyone|subscribers|only_paid|none, optional): who may commentdefault_comment_sort(best_first|most_recent_first|oldest_first, optional)cover_image(string, optional): the social preview image. A URL already onsubstack-post-media.s3.amazonaws.comorsubstackcdn.comis used as-is; anything else is downloaded and re-hosted on Substack first, under the same guards asupload_imagesocial_title(string, optional): the title used when the post is shared elsewheredescription(string, optional): the social preview description — not the subtitlesearch_engine_title(string, optional)search_engine_description(string, optional)slug(string, optional): the post's URL slug
Returns: {draft_id, updated_fields, draft_title, draft_subtitle, audience, is_published, cover_image, cover_image_rehosted_from}.
A call with no field to change is refused rather than sent as a no-op. cover_image is the URL that
actually landed, which differs from the one passed when it was re-hosted.
Inputs:
draft_id(number): the id returned bylist_postsorcreate_draft_postsend(boolean, optional): email the post to subscribers. Defaults tofalse, unlike the Substack API's own default — the post goes live on the web either way, but an email cannot be recalled, so it has to be asked for explicitly.
Returns: {status, draft_id, post_id, title, slug, canonical_url, emailed, email_sent_at}.
emailed is what was asked for; email_sent_at is the server's own record of whether it mailed.
The email intent is written to the draft's should_send_email before publishing, as well as being
passed on the publish call. That field is where the dashboard keeps the decision and it defaults to
true, so setting only one of the two would risk mailing the whole list if the endpoint reads the
draft rather than the request body.
There is no unpublish tool: publishing cannot be undone from this server.
Inputs:
draft_id(number): the id returned bylist_postsorcreate_draft_post
Returns: {status, draft_id, draft_title}.
Substack deletes drafts and published posts through the same endpoint, so this tool reads the target first and refuses if it is published — removing a live post is irreversible and is left to the dashboard.
Inputs:
full(boolean, optional): return all 111 fields (~24 KB) instead of the projection. Defaults tofalse.
Returns: by default a projection — name, subdomain, custom domain, hero text, copyright, sender
name, logo, plans, payment state and the community/podcast flags — plus _meta naming how many
fields were dropped. The full payload is mostly notification toggles and the raw HTML of the welcome
email, terms and privacy pages.
Inputs:
full(boolean, optional): include the completesubscriptionsarray. Defaults tofalse.
Returns: {id, name, handle, bio, photo_url, publications, primary_publication_id, subscription_count}. publications lists every publication the session has a role on, which is how
to discover that SUBSTACK_PUBLICATION_URL is not the only one it could be pointed at.
Inputs:
include_hidden(boolean, optional): include tags not shown in the navigation. Defaults totrue.
Returns: {total, returned, tags}, each {id, name, slug, hidden}. Tag ids are UUIDs, not
integers — unlike every other id in this API.
Inputs:
post_id(number): the id fromlist_posts. Works for drafts too.
Returns: {post_id, count, tags}, each {post_tag_id, name, slug, hidden, association_id}.
The underlying endpoint answers only UUIDs, so this resolves the names against the publication's tag
list. Neither get_draft nor list_posts carries tags, so this is the only way to read them back.
Inputs:
post_id(number): the id fromlist_posts. Works for drafts too.tag_name(string): matched case-insensitively against existing tagscreate_if_missing(boolean, optional): create the tag when no name matches. Defaults totrue; set it tofalseto have a typo reported instead of turned into a new tag.
Returns: {status, post_id, tag, tag_created, association_id} where status is tagged or
already_tagged — re-adding a tag the post already has answers a bare 400 upstream, so it is
checked first.
Takes a name rather than an id because the ids are UUIDs, which no caller could reasonably hold.
Inputs:
post_id(number): the id fromlist_postslimit(number, optional): 1–100, defaults to 50
Returns: {post_id, returned, automod_hidden_count, comments}. Each comment carries its author,
plain-text body, reaction and reply counts, and its position in the thread (parent_comment_id,
depth). Comments withheld by Substack's automod are counted, not merged in — they arrive in a
separate array upstream, and dropping them silently would turn "held" into "nobody commented".
Inputs:
post_id(number): the id fromlist_postsbody(string): plain text; Substack converts it server-side
Returns: {status, post_id, comment}.
This is published under your name. The full text is logged at info before the request, since the log
is the only record of what was said. This server does not expose deletion, but the comment can be
removed from the Substack UI — unlike a restack, a comment does have an id of its own.
The seven tools below read substack.com, not your publication. They are about the account as a
reader — what it subscribes to, what is in its inbox and feed — which is a different host and a
different id space from the publisher surface above.
Inputs:
limit(number, optional): 1–500, defaults to 100active_only(boolean, optional): exclude paused and expired subscriptions. Defaults totrue.
Returns: {returned, pages_fetched, subscriptions}, each with plan, membership_state,
is_founding, is_favorite and whether emails are off. Pages internally up to 20 requests and says
truncated: true if that bound is what stopped it.
Not to be confused with list_subscribers, which is who subscribes to you.
Inputs:
limit(number, optional): 1–100, defaults to 20after(string, optional): thenext_afterfrom a previous response. A timestamp, not an opaque cursor — this endpoint's owncursorfield is always null.
Returns: {returned, more, next_after, posts}, each post summarised with its reading state
(is_read, read_progress, is_saved). The Inbox sends every post's full body; it is dropped here,
so use get_reader_post to read one.
Inputs:
post_id(number): fromlist_reader_postsorget_reader_feedinclude_body(boolean, optional): defaults totrue
Returns: the post's metadata plus body_html. body_truncated: true means the body was withheld
behind a paywall this session does not clear — preview_text still carries the teaser.
The body stays HTML: converting it would mean a new dependency or a regex pass over markup, and a regex HTML converter mangles nested lists and embeds silently.
Inputs:
tab(string, optional): tab id —for-you(default) orsubscribed. Never the display name: those are localized.limit(number, optional): 1–50, defaults to 20cursor(string, optional): thenext_cursorfrom a previous responseinclude_tabs(boolean, optional): also return the available tab ids
Returns: {tab, returned, next_cursor, items}. Each item is a note or a post.
non_content_items_skipped counts the "people to follow" blocks Substack mixes into the array, which
carry no content at all.
Inputs:
user_id(number, optional): defaults toSUBSTACK_USER_ID— your own accounttype(all|notes|posts, optional): defaults toalllimit(number, optional): 1–50, defaults to 20cursor(string, optional)
Returns: {user_id, type, returned, next_cursor, items}. When filtering, read_from_profile
reports how many entries the page actually held — otherwise "3 notes out of 20 entries read" would
look like "this account has written 3 notes".
Inputs:
comment_id(number): without thec-prefix Substack uses in urlsinclude_replies(boolean, optional): defaults totrue
Returns: {comment, branch_count, replies_returned, more_branches, next_cursor, branches}. Each
branch is a direct reply plus its descendants, with parent_comment_id and depth resolved.
Inputs:
comment_id(number): the Note to restack, fromget_reader_feedorget_profile_feedtab_id(string, optional): defaults tofor-you
Returns: {status, comment_id, restack_id, note}.
This is public and appears on your profile, and cannot be undone from here: a restack has no id of
its own — it surfaces the original Note with context: comment_restack
Substack MCP questions
How do I add Substack MCP to Claude Code?
Run claude mcp add --env 'SUBSTACK_SESSION_TOKEN=<YOUR_SUBSTACK_SESSION_TOKEN>' --env 'SUBSTACK_PUBLICATION_URL=<SUBSTACK_PUBLICATION_URL>' --env 'SUBSTACK_USER_ID=<SUBSTACK_USER_ID>' substack-mcp -- npx -y substack-mcp in your terminal. Add --scope user to make it available in every project.
How do I add Substack MCP to Cursor?
Open ~/.cursor/mcp.json and add the server under mcpServers, using the Cursor config shown in the install section above.
What tools does Substack MCP provide?
It exposes 55 tools, including title, subtitle, body, filters, search.
How do I authenticate the Substack MCP server?
You sign in to Substack and provide your publication URL, session token and user ID via environment variables; the server uses your authenticated web session.
Can the Substack MCP server create and update posts?
Yes, it can create drafts, replace a draft's body with a structured document, and manage posts across drafts, published and scheduled lists.
Can I filter and export my Substack subscribers?
Yes, you can filter subscribers by many columns and operators, and export the full matching set with engagement metrics.
Can I upload images to Substack with this server?
Yes, upload_image re-hosts an image from a URL or local file on Substack and returns a URL usable in post bodies or cover images.
How many tools does the server expose?
The server exposes 27 tools covering posts, subscribers, analytics, feeds, tags, comments and images.