{
  "markdown": "# Substack MCP Server\n\nA Model Context Protocol (MCP) Server for [Substack](https://substack.com) enabling LLM clients to interact with Substack's API for automations like creating posts, managing drafts, and more.\n\n[![Docker Pulls](https://img.shields.io/docker/pulls/marcomoauro/substack-mcp.svg)](https://hub.docker.com/r/marcomoauro/substack-mcp)\n[![npm downloads](https://img.shields.io/npm/dm/substack-mcp.svg)](https://www.npmjs.com/package/substack-mcp)\n\nCreate and publish posts, work with subscribers and analytics, browse your reader feeds, manage\ntags and comments, and upload images — 27 tools exposed through one MCP server.\n\n> [!IMPORTANT]\n> Substack does not provide a public API for these operations. This server uses your authenticated\n> web session. Treat the session token exactly like a password: keep it local, never commit it, and\n> never include it or a complete Cookie header in a bug report.\n\n## Quick start\n\nThe fastest installation uses [Node.js 22 or newer](https://nodejs.org/) and `npx`.\n\n### 1. Collect your Substack credentials\n\nSign in to Substack in your browser and open your publication dashboard. You need three values:\n\n- **Publication URL** — the full base URL of your publication, for example\n  `https://your-publication.substack.com`.\n- **Session token** — open your browser's developer tools, select **Network**, filter to\n  **Fetch/XHR**, and reload the dashboard. Open a successful authenticated request to your\n  publication. Under **Request Headers**, find the `Cookie` header and locate a session cookie named\n  `substack.sid` or `connect.sid`. Copy its value without the cookie name or the rest of the header.\n  If both names appear with different values, test them separately and locally with the read-only\n  verification in step 3; never paste either value into an issue.\n- **User ID** — in the same Network panel, search for a successful `publication_user` request. In\n  its JSON response, copy the numeric `id` inside the `user` object.\n\nIf the browser UI differs, the illustrated [credential guide](https://implementing.substack.com/p/mcp-server-for-substack)\nshows the same requests. If authentication later stops working, sign in again and repeat these steps\nto obtain the current token.\n\n### 2. Add the server to your MCP client\n\nFor clients that accept MCP JSON configuration, add:\n\n```json\n{\n  \"mcpServers\": {\n    \"substack\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"substack-mcp@latest\"],\n      \"env\": {\n        \"SUBSTACK_PUBLICATION_URL\": \"https://your-publication.substack.com\",\n        \"SUBSTACK_SESSION_TOKEN\": \"your-session-token\",\n        \"SUBSTACK_USER_ID\": \"your-user-id\"\n      }\n    }\n  }\n}\n```\n\nReplace the three example values, save the configuration, and restart your MCP client. Consult your\nclient's documentation if it uses a different configuration format.\n\n### 3. Verify the connection\n\nAsk your client:\n\n> List my five most recent Substack drafts.\n\nThe client should call `list_posts` with `status: \"drafts\"`. If it fails, check the client's MCP\nlogs and the [logging section](#-logs) below before opening an issue.\n\n### Docker quick start\n\nTo use the published Docker image instead of Node.js, add this server configuration:\n\n```json\n{\n  \"mcpServers\": {\n    \"substack\": {\n      \"command\": \"docker\",\n      \"args\": [\n        \"run\", \"-i\", \"--rm\",\n        \"-e\", \"SUBSTACK_PUBLICATION_URL\",\n        \"-e\", \"SUBSTACK_SESSION_TOKEN\",\n        \"-e\", \"SUBSTACK_USER_ID\",\n        \"marcomoauro/substack-mcp:latest\"\n      ],\n      \"env\": {\n        \"SUBSTACK_PUBLICATION_URL\": \"https://your-publication.substack.com\",\n        \"SUBSTACK_SESSION_TOKEN\": \"your-session-token\",\n        \"SUBSTACK_USER_ID\": \"your-user-id\"\n      }\n    }\n  }\n}\n```\n\n## 🛠 Available Tools\n\n<details>\n<summary><strong>create_draft_post</strong> - Create a draft post</summary>\n\n**Inputs**:\n- `title` (string): Title of the post\n- `subtitle` (string): Subtitle of the post\n- `body` (string): Body of the post. Plain text becomes one paragraph per line — **Markdown is not\n  interpreted**, so `## Heading` arrives literally. A JSON string of a Substack document also works\n  and is validated against the same schema `set_post_body` publishes, so an unrecognised node name\n  is an error rather than a silently mangled post.\n\n**Returns**: `{draft_id, is_published}`. Pass `draft_id` to `get_draft` to read the draft back.\n\nFor anything structured — headings, lists, links, code, images, a paywall — use `set_post_body`\nafter creating the draft: the schema is published there, so the calling model can read the node\nvocabulary rather than guess at it.\n</details>\n\n<details>\n<summary><strong>list_subscribers</strong> - List and filter your subscribers</summary>\n\nExposes the same filtering the Subscribers dashboard offers: 48 columns, 18 operators, free-text\nsearch, sorting and pagination.\n\n**Inputs**:\n- `filters` (array, optional): conditions combined with **AND**, each `{column, operator, value}`\n- `search` (string, optional): free text matched against subscriber name and email\n- `sort_by` (string, optional): any filterable column\n- `sort_direction` (`asc` | `desc`, optional): defaults to `desc`\n- `limit` (number, optional): 1–100, defaults to 25\n- `offset` (number, optional): for paging\n\nWhich operators a column accepts depends on its type:\n\n| Type | Operators |\n|---|---|\n| `Int` | `is` `is_not` `gt` `gte` `lt` `lte` |\n| `String` | `is` `is_not` `is_any_of` `contains` `starts_with` `ends_with` `includes_none` |\n| `DateTime` | `is_on` `is_after` `is_on_or_after` `is_before` `is_on_or_before` |\n| `Array` (`tag_ids`, `emails_enabled`) | `includes_any` `includes_all` `includes_none` |\n| `subscription_type`, `group_membership` | `is` `is_not` `is_any_of` |\n\nThe columns cover subscriber identity (name, email, country, state, group membership),\nsubscription (type, start/expiry/cancel dates, revenue, Stripe plan, attribution), email\nengagement (opens and unique opens over 7d/30d/6mo, links clicked, sections) and site engagement\n(post views, unique posts seen, comments, shares, days active, activity rating). The full list\nwith types reaches the client in the tool's JSON Schema, so a model does not have to guess names.\n\n**Returns**: `{count, returned, limit, offset, subscribers}`. `count` is the total matching the\nfilters regardless of `limit`, so a call with `limit: 1` is a cheap way to size a segment.\n\n> **Note**: engagement columns can be *filtered* on here but are not part of the records this tool\n> returns — Substack takes the fields it returns from the publication's saved Display settings and\n> ignores a per-request column list. Use **`export_subscribers`** to read their values.\n\nThere is no OR and no nesting: anything needing OR has to be issued as separate calls.\n</details>\n\n<details>\n<summary><strong>export_subscribers</strong> - Export subscribers with every column value</summary>\n\nThe way to actually *read* the engagement metrics `list_subscribers` can only filter on: email opens\nover 7d/30d/6mo, unique emails seen, post views, unique posts seen, comments, shares, links clicked,\ndays active and activity rating.\n\n**Inputs**:\n- `filters` (array, optional): the same conditions as `list_subscribers`, combined with AND\n- `search` (string, optional): free text matched against subscriber name and email\n- `columns` (array, optional): which columns to include, defaulting to **all** of them\n- `max_wait_seconds` (number, optional): 1–600, defaulting to 120\n\n**Returns**: `{count, columns, missing_columns, unmapped_columns, export_id, subscribers}`, where\neach subscriber is keyed by column name.\n\nSubstack generates the file asynchronously, so the tool creates a subscriber set, requests the\nexport, polls until it is ready and downloads it. A small export lands in a few seconds. If the wait\nbudget runs out the tool says so and names the `export_id` rather than blocking.\n\n> **Two caveats**, both verified against the live API:\n> - `tag_ids` and `group_membership` **cannot** be exported. Substack drops them silently rather\n>   than failing, so they are reported in `missing_columns` — asking for all 48 columns returns 46.\n> - Values arrive **display-formatted**, not raw: revenue is `\"€50.00\"` here and the number `50`\n>   through `list_subscribers`. Dates are ISO strings.\n\nThere is no paging: an export covers the whole matching set.\n</details>\n\n<details>\n<summary><strong>list_posts</strong> - List drafts, published or scheduled posts</summary>\n\n**Inputs**:\n- `status` (`drafts` | `published` | `scheduled`): which list to read\n- `search` (string, optional): free text matched against title and content\n- `limit` (number, optional): 1–100, defaults to 25\n- `offset` (number, optional): for paging\n- `sort_direction` (`asc` | `desc`, optional): drafts and published posts are newest-first,\n  scheduled posts soonest-first\n\n**Returns**: `{status, total, returned, limit, offset, posts}`, each post summarised — use\n`get_draft` for the full content of an unpublished one.\n</details>\n\n<details>\n<summary><strong>get_draft</strong> - Read one draft in full</summary>\n\n**Inputs**:\n- `draft_id` (number): the id returned by `list_posts` or `create_draft_post`\n\n**Returns**: the draft as Substack stores it, body and audience/email settings included.\n</details>\n\n<details>\n<summary><strong>set_post_body</strong> - Replace a draft's body with a structured document</summary>\n\nThe only way to write structured content: headings, lists, links, code blocks, quotes, images,\nbuttons and a paywall. `create_draft_post` takes plain text; this takes the document Substack\nactually stores, and its schema is published in `tools/list` so the calling model can read the node\nvocabulary instead of guessing.\n\n**Inputs**:\n- `draft_id` (number): the id returned by `list_posts` or `create_draft_post`\n- `body` (object): a Substack ProseMirror document — `{type: 'doc', content: [...]}`\n\nFifteen node types are accepted: `paragraph`, `heading`, `bullet_list`, `ordered_list`, `list_item`,\n`blockquote`, `highlighted_code_block`, `code_block`, `horizontal_rule`, `captionedImage`, `button`,\n`paywall`, `youtube2`, plus `digestPostEmbed`, `substack_mentions` and `directMessage` passed through\nunchanged so a document read with `get_draft` can be written back. Marks: `strong`, `em`, `code`,\n`strikethrough`, `link`.\n\n**Returns**: `{draft_id, nodes}`, where `nodes` counts what was stored by type — so a caller that\nasked for a paywall can confirm there is one. Validation cannot report a node that was never sent.\n\nThree things worth knowing:\n- **An image must already be hosted by Substack.** `image2.src` pointing at an external URL is\n  stored but does not render. Use `upload_image` to re-host one and get a `src` that works.\n- **A document may contain at most one `paywall`.** Substack accepts two and renders both, leaving\n  it undefined which one cuts the post, so this tool refuses the second.\n- **`ordered_list` numbers from `attrs.order`, not `attrs.start`.** A list given only `start`\n  renders from 1 with no error.\n</details>\n\n<details>\n<summary><strong>upload_image</strong> - Host an image on Substack, from a URL or a local file</summary>\n\nSubstack's editor uploads images as base64 data URIs to `POST /api/v1/image`, which answers with a\nSubstack-hosted URL. `image2.src` in `set_post_body` and `cover_image` in `update_draft` only render\nsuch a URL, so this tool is the bridge. Substack itself only re-fetches URLs already in its own\nstorage, so the image is encoded here rather than being handed off.\n\n**Inputs** — exactly one of `url` or `path`:\n- `url` (string): the http(s) URL of an image to download and re-host\n- `path` (string): absolute path to an image file on the machine running this server, read straight\n  from disk with no download\n- `post_id` (number, optional): the post the image belongs to; its effect is unconfirmed\n\n**Returns**: `{id, url, content_type, bytes, width, height}` — put `url` into an `image2.src` when\ncalling `set_post_body`, or into `cover_image` when calling `update_draft`.\n\nA **download** is guarded: only `http`/`https`, private and loopback hosts are refused after DNS\nresolution (redirects are re-checked at every hop), the content type must be an image, HEIC is\nrejected with a note to convert it, and the image may not exceed 10 MB.\n\nA **local file** is guarded differently, because it has no `Content-Type` header to trust. The path\nmust be absolute — a relative one would resolve against this server's working directory, not the\ncalling client's — and the type is read from the file's magic bytes rather than its extension, so a\nnon-image with an image extension is caught here instead of at Substack. PNG, JPEG, GIF and WebP are\naccepted; HEIC and SVG are not. The same 10 MB cap applies, checked against the file size before the\nfile is read. Note that `path` reads whatever absolute path it is given: if that matters in your\nsetup, do not expose this server to a client you would not trust with your filesystem.\n</details>\n\n<details>\n<summary><strong>update_draft</strong> - Change a draft's title, subtitle or any of its Post settings</summary>\n\nThe update is **partial**: only the fields you pass change, and the body is left alone.\n\n**Inputs**:\n- `draft_id` (number): the id returned by `list_posts` or `create_draft_post`\n- `draft_title` (string, optional)\n- `draft_subtitle` (string, optional)\n- `audience` (`everyone` | `only_paid` | `only_free` | `founding`, optional)\n- `write_comment_permissions` (`everyone` | `subscribers` | `only_paid` | `none`, optional): who may comment\n- `default_comment_sort` (`best_first` | `most_recent_first` | `oldest_first`, optional)\n- `cover_image` (string, optional): the social preview image. A URL already on `substack-post-media.s3.amazonaws.com` or `substackcdn.com` is used as-is; anything else is downloaded and re-hosted on Substack first, under the same guards as `upload_image`\n- `social_title` (string, optional): the title used when the post is shared elsewhere\n- `description` (string, optional): the social preview description — *not* the subtitle\n- `search_engine_title` (string, optional)\n- `search_engine_description` (string, optional)\n- `slug` (string, optional): the post's URL slug\n\n**Returns**: `{draft_id, updated_fields, draft_title, draft_subtitle, audience, is_published, cover_image, cover_image_rehosted_from}`.\nA call with no field to change is refused rather than sent as a no-op. `cover_image` is the URL that\nactually landed, which differs from the one passed when it was re-hosted.\n</details>\n\n<details>\n<summary><strong>publish_draft</strong> - Publish a draft</summary>\n\n**Inputs**:\n- `draft_id` (number): the id returned by `list_posts` or `create_draft_post`\n- `send` (boolean, optional): email the post to subscribers. **Defaults to `false`**, unlike the\n  Substack API's own default — the post goes live on the web either way, but an email cannot be\n  recalled, so it has to be asked for explicitly.\n\n**Returns**: `{status, draft_id, post_id, title, slug, canonical_url, emailed, email_sent_at}`.\n`emailed` is what was *asked* for; `email_sent_at` is the server's own record of whether it mailed.\n\nThe email intent is written to the draft's `should_send_email` **before** publishing, as well as being\npassed on the publish call. That field is where the dashboard keeps the decision and it defaults to\n`true`, so setting only one of the two would risk mailing the whole list if the endpoint reads the\ndraft rather than the request body.\n\nThere is no unpublish tool: publishing cannot be undone from this server.\n</details>\n\n<details>\n<summary><strong>delete_draft</strong> - Delete an unpublished draft</summary>\n\n**Inputs**:\n- `draft_id` (number): the id returned by `list_posts` or `create_draft_post`\n\n**Returns**: `{status, draft_id, draft_title}`.\n\nSubstack deletes drafts and published posts through the *same* endpoint, so this tool reads the\ntarget first and **refuses if it is published** — removing a live post is irreversible and is left\nto the dashboard.\n</details>\n\n<details>\n<summary><strong>get_publication</strong> - Read your publication's settings</summary>\n\n**Inputs**:\n- `full` (boolean, optional): return all 111 fields (~24 KB) instead of the projection.\n  Defaults to `false`.\n\n**Returns**: by default a projection — name, subdomain, custom domain, hero text, copyright, sender\nname, logo, plans, payment state and the community/podcast flags — plus `_meta` naming how many\nfields were dropped. The full payload is mostly notification toggles and the raw HTML of the welcome\nemail, terms and privacy pages.\n</details>\n\n<details>\n<summary><strong>get_user_profile</strong> - Read the account behind the session</summary>\n\n**Inputs**:\n- `full` (boolean, optional): include the complete `subscriptions` array. Defaults to `false`.\n\n**Returns**: `{id, name, handle, bio, photo_url, publications, primary_publication_id,\nsubscription_count}`. `publications` lists every publication the session has a role on, which is how\nto discover that `SUBSTACK_PUBLICATION_URL` is not the only one it could be pointed at.\n</details>\n\n<details>\n<summary><strong>list_publication_tags</strong> - List the tags defined on your publication</summary>\n\n**Inputs**:\n- `include_hidden` (boolean, optional): include tags not shown in the navigation. Defaults to `true`.\n\n**Returns**: `{total, returned, tags}`, each `{id, name, slug, hidden}`. Tag ids are **UUIDs**, not\nintegers — unlike every other id in this API.\n</details>\n\n<details>\n<summary><strong>get_post_tags</strong> - List the tags on one post</summary>\n\n**Inputs**:\n- `post_id` (number): the id from `list_posts`. Works for drafts too.\n\n**Returns**: `{post_id, count, tags}`, each `{post_tag_id, name, slug, hidden, association_id}`.\n\nThe underlying endpoint answers only UUIDs, so this resolves the names against the publication's tag\nlist. Neither `get_draft` nor `list_posts` carries tags, so this is the only way to read them back.\n</details>\n\n<details>\n<summary><strong>add_tag_to_post</strong> - Tag a post</summary>\n\n**Inputs**:\n- `post_id` (number): the id from `list_posts`. Works for drafts too.\n- `tag_name` (string): matched case-insensitively against existing tags\n- `create_if_missing` (boolean, optional): create the tag when no name matches. Defaults to `true`;\n  set it to `false` to have a typo reported instead of turned into a new tag.\n\n**Returns**: `{status, post_id, tag, tag_created, association_id}` where `status` is `tagged` or\n`already_tagged` — re-adding a tag the post already has answers a bare `400` upstream, so it is\nchecked first.\n\nTakes a name rather than an id because the ids are UUIDs, which no caller could reasonably hold.\n</details>\n\n<details>\n<summary><strong>get_post_comments</strong> - Read the comments on one of your posts</summary>\n\n**Inputs**:\n- `post_id` (number): the id from `list_posts`\n- `limit` (number, optional): 1–100, defaults to 50\n\n**Returns**: `{post_id, returned, automod_hidden_count, comments}`. Each comment carries its author,\nplain-text body, reaction and reply counts, and its position in the thread (`parent_comment_id`,\n`depth`). Comments withheld by Substack's automod are **counted, not merged in** — they arrive in a\nseparate array upstream, and dropping them silently would turn \"held\" into \"nobody commented\".\n</details>\n\n<details>\n<summary><strong>comment_on_post</strong> - Comment on one of your posts</summary>\n\n**Inputs**:\n- `post_id` (number): the id from `list_posts`\n- `body` (string): plain text; Substack converts it server-side\n\n**Returns**: `{status, post_id, comment}`.\n\nThis is published under your name. The full text is logged at `info` before the request, since the log\nis the only record of what was said. This server does not expose deletion, but the comment can be\nremoved from the Substack UI — unlike a restack, a comment does have an id of its own.\n</details>\n\nThe seven tools below read **`substack.com`**, not your publication. They are about the account as a\n*reader* — what it subscribes to, what is in its inbox and feed — which is a different host and a\ndifferent id space from the publisher surface above.\n\n<details>\n<summary><strong>list_subscriptions</strong> - List what this account subscribes to</summary>\n\n**Inputs**:\n- `limit` (number, optional): 1–500, defaults to 100\n- `active_only` (boolean, optional): exclude paused and expired subscriptions. Defaults to `true`.\n\n**Returns**: `{returned, pages_fetched, subscriptions}`, each with plan, `membership_state`,\n`is_founding`, `is_favorite` and whether emails are off. Pages internally up to 20 requests and says\n`truncated: true` if that bound is what stopped it.\n\nNot to be confused with `list_subscribers`, which is who subscribes to *you*.\n</details>\n\n<details>\n<summary><strong>list_reader_posts</strong> - The reader Inbox</summary>\n\n**Inputs**:\n- `limit` (number, optional): 1–100, defaults to 20\n- `after` (string, optional): the `next_after` from a previous response. A **timestamp**, not an\n  opaque cursor — this endpoint's own `cursor` field is always null.\n\n**Returns**: `{returned, more, next_after, posts}`, each post summarised with its reading state\n(`is_read`, `read_progress`, `is_saved`). The Inbox sends every post's full body; it is dropped here,\nso use `get_reader_post` to read one.\n</details>\n\n<details>\n<summary><strong>get_reader_post</strong> - Read any post in full</summary>\n\n**Inputs**:\n- `post_id` (number): from `list_reader_posts` or `get_reader_feed`\n- `include_body` (boolean, optional): defaults to `true`\n\n**Returns**: the post's metadata plus `body_html`. `body_truncated: true` means the body was withheld\nbehind a paywall this session does not clear — `preview_text` still carries the teaser.\n\nThe body stays HTML: converting it would mean a new dependency or a regex pass over markup, and a\nregex HTML converter mangles nested lists and embeds *silently*.\n</details>\n\n<details>\n<summary><strong>get_reader_feed</strong> - The Notes feed</summary>\n\n**Inputs**:\n- `tab` (string, optional): tab **id** — `for-you` (default) or `subscribed`. Never the display name:\n  those are localized.\n- `limit` (number, optional): 1–50, defaults to 20\n- `cursor` (string, optional): the `next_cursor` from a previous response\n- `include_tabs` (boolean, optional): also return the available tab ids\n\n**Returns**: `{tab, returned, next_cursor, items}`. Each item is a `note` or a `post`.\n`non_content_items_skipped` counts the \"people to follow\" blocks Substack mixes into the array, which\ncarry no content at all.\n</details>\n\n<details>\n<summary><strong>get_profile_feed</strong> - What one account has published</summary>\n\n**Inputs**:\n- `user_id` (number, optional): defaults to `SUBSTACK_USER_ID` — your own account\n- `type` (`all` | `notes` | `posts`, optional): defaults to `all`\n- `limit` (number, optional): 1–50, defaults to 20\n- `cursor` (string, optional)\n\n**Returns**: `{user_id, type, returned, next_cursor, items}`. When filtering, `read_from_profile`\nreports how many entries the page actually held — otherwise \"3 notes out of 20 entries read\" would\nlook like \"this account has written 3 notes\".\n</details>\n\n<details>\n<summary><strong>get_comment_thread</strong> - Read a Note and its replies</summary>\n\n**Inputs**:\n- `comment_id` (number): without the `c-` prefix Substack uses in urls\n- `include_replies` (boolean, optional): defaults to `true`\n\n**Returns**: `{comment, branch_count, replies_returned, more_branches, next_cursor, branches}`. Each\nbranch is a direct reply plus its descendants, with `parent_comment_id` and `depth` resolved.\n</details>\n\n<details>\n<summary><strong>restack_item</strong> - Restack a Note</summary>\n\n**Inputs**:\n- `comment_id` (number): the Note to restack, from `get_reader_feed` or `get_profile_feed`\n- `tab_id` (string, optional): defaults to `for-you`\n\n**Returns**: `{status, comment_id, restack_id, note}`.\n\nThis is public and appears on your profile, and **cannot be undone from here**: a restack has no id of\nits own — it surfaces the original Note with `context: comment_restack` — so there is nothing for this\nserver to delete. Remove it from the Substack UI.\n\nNotes only. Restacking a *post* is not offered: that call answers `404` even for a published post on\nyour own publication, so a `post_id` parameter would produce an error that reads as the post being\ngone rather than as the tool being wrong.\n</details>\n\n<details>\n<summary><strong>get_publication_stats</strong> - Read the headline stats</summary>\n\n**Inputs**: none.\n\n**Returns**: total and recent subscribers, email and app subscribers, ARR, site views and the\n30-day email open rate, each with its change where Substack reports one. If one of the underlying\nendpoints fails the rest are still returned, and the failure is named under `errors`.\n\nFor anything deeper, use `get_analytics`.\n</details>\n\n<details>\n<summary><strong>get_post_stats</strong> - Rank your posts by any of 43 metrics</summary>\n\nWhich post actually grew the list, which was worth most, which cost you subscribers. The dashboard's\n\"Posts\" tab, sortable and paged.\n\n**Inputs**:\n- `order_by` (string, optional): any of the 43 metrics, defaulting to `post_date`\n- `order_direction` (`asc` | `desc`, optional): defaults to `desc`\n- `limit` (number, optional): 1–100, defaults to 25\n- `offset` (number, optional): for paging the archive\n\nThe metrics worth reaching for:\n\n| group | fields |\n|---|---|\n| conversion | `signups` `subscribes` `founding_subscribes` `annual_subscribes` `monthly_subscribes` `free_trials` `free_to_paid_upgrades` `signups_within_1_day` `estimated_value` |\n| churn | `unsubscribes` |\n| reading | `opens` `open_rate` `clicks` `click_through_rate` `views` `subscribers_finished_post` |\n| social | `likes` `shares` `restacks` `engagement_rate` `unique_engagements` |\n| delivery | `queued` `sent` `delivered` `dropped` |\n| video / podcast | `video_views` `video_minutes_watched` `downloads` `downloads_day30` … |\n\n**Returns**: `{total, returned, limit, offset, order_by, order_direction, posts}`. `total` is the\nwhole archive, not the page; `order_by` and `order_direction` are echoed so a ranking is never read\nwithout knowing what produced it.\n\n> **Two caveats**, both verified:\n> - **There is no date filter.** `from_date`/`to_date` are ignored by this endpoint — `total` does not\n>   change — so the schema does not offer them. Narrow by sorting and paging instead.\n> - Ranking by a **rate** (`open_rate`, `engagement_rate`, `click_through_rate`) descending puts posts\n>   with no data first, because `null` sorts before numbers. The tool does not filter them out, since\n>   that would silently answer a different question.\n>\n> `order_by` is an enum on purpose: the API answers `200` for a field it does not recognise and\n> returns an arbitrary order, so a typo would produce a ranking that looks authoritative.\n</details>\n\n<details>\n<summary><strong>get_analytics</strong> - Read one of 16 publication-level reports</summary>\n\nEverything behind the dashboard's Stats tabs, as one tool with a `report` enum rather than\nseventeen near-identical tools.\n\n**Inputs**:\n- `report` (string): which report to read — see the table below\n- `from_date`, `to_date` (string, optional): `YYYY-MM-DD`. Used only by the reports covering a\n  period, which default to the last 30 days\n- `limit` (number, optional): 1–100, used only by `audience_overlap` and `subscriber_notes`\n\n| report | what it tells you |\n|---|---|\n| `retention` | cohort retention — how much of each signup cohort is still subscribed months later |\n| `retention_summary` | headline retention at 1, 6 and 12 months |\n| `unsubscribes` / `unsubscribes_timeseries` | churn, with the reasons given |\n| `growth_sources` | where new subscribers came from, ranked |\n| `growth_events` | the individual growth events in a window |\n| `referrals_leaderboard` / `referrals_summary` | who refers most; gifts sent, accepted, converted |\n| `audience_overlap` | other Substacks whose audience overlaps yours — the collaboration shortlist |\n| `audience_locations` | how many countries and US states your subscribers span |\n| `subscriber_notes` | recent Notes written by your subscribers |\n| `paid_subscriber_growth` | paid growth rate, new subscriptions, expirations |\n| `subscribers_timeseries`, `followers_timeseries`, `arr_timeseries` | counts and revenue over time |\n| `network_attribution` | what share of subscribers arrived via the Substack network |\n\n**Returns**: `{report, params, ignored_params, data}`. `params` is what was actually sent, defaults\nincluded — the same report answers very differently over a different window, so the numbers mean\nlittle without it. `ignored_params` names anything you passed that the chosen report does not\naccept, rather than dropping it silently.\n\n> Two neighbouring endpoints are deliberately **not** exposed: `audience_insights/location` (the\n> subscriber map) and `visitor_sources` answer `400` even for Substack's own dashboard, so they are\n> broken upstream rather than mis-called.\n</details>\n\n## 🏗 Running from Source\n\nUse this if you want to hack on the server itself. There is no build step — the sources are plain\nESM and run as they are.\n\n### Node.js\n\n```bash\ngit clone https://github.com/marcomoauro/substack-mcp.git\ncd substack-mcp\nnpm ci\n```\n\nThen add to your MCP config:\n\n```json\n{\n  \"mcpServers\": {\n    \"substack-api\": {\n      \"command\": \"node\",\n      \"args\": [\"<FULL_PATH_TO_PROJECT>/src/index.js\"],\n      \"env\": {\n        \"SUBSTACK_PUBLICATION_URL\": \"<YOUR_PUBLICATION_URL>\",\n        \"SUBSTACK_SESSION_TOKEN\": \"<YOUR_SESSION_TOKEN>\",\n        \"SUBSTACK_USER_ID\": \"<YOUR_USER_ID>\"\n      }\n    }\n  }\n}\n```\n\n### Docker (build from source)\n\n```bash\ngit clone https://github.com/marcomoauro/substack-mcp.git\ncd substack-mcp\ndocker build -t substack-mcp .\n```\n\nThen add to your MCP config:\n\n```json\n{\n  \"mcpServers\": {\n    \"substack-api\": {\n      \"command\": \"docker\",\n      \"args\": [\n        \"run\", \"-i\", \"--rm\",\n        \"-e\", \"SUBSTACK_PUBLICATION_URL\",\n        \"-e\", \"SUBSTACK_SESSION_TOKEN\",\n        \"-e\", \"SUBSTACK_USER_ID\",\n        \"substack-mcp\"\n      ],\n      \"env\": {\n        \"SUBSTACK_PUBLICATION_URL\": \"<YOUR_PUBLICATION_URL>\",\n        \"SUBSTACK_SESSION_TOKEN\": \"<YOUR_SESSION_TOKEN>\",\n        \"SUBSTACK_USER_ID\": \"<YOUR_USER_ID>\"\n      }\n    }\n  }\n}\n```\n\n## 🪵 Logs\n\nThe server logs what it does as one JSON object per line, on **stderr** — MCP clients collect it\ninto their own log file (on macOS, Claude Desktop writes it to\n`~/Library/Logs/Claude/mcp-server-substack-api.log`). It is the fastest way to see what your LLM\nactually sent when a call does not do what you expected:\n\n```json\n{\"ts\":\"2026-08-07T10:12:03.114Z\",\"level\":\"info\",\"msg\":\"tool.call.start\",\"tool\":\"create_draft_post\",\"args\":{\"title\":\"My title\",\"subtitle\":\"My subtitle\",\"body\":\"…\"}}\n{\"ts\":\"2026-08-07T10:12:03.402Z\",\"level\":\"info\",\"msg\":\"substack.response\",\"status\":200,\"duration_ms\":287}\n{\"ts\":\"2026-08-07T10:12:03.403Z\",\"level\":\"info\",\"msg\":\"create_draft_post.created\",\"draft_id\":167712345}\n```\n\nSet the optional `SUBSTACK_MCP_LOG_LEVEL` env var alongside your credentials to change how much\nis written:\n\n| Value | What you get |\n|---|---|\n| `silent` | nothing |\n| `error` | failed calls only |\n| `warn` | the above, plus every answer the client received as an error — including calls rejected for bad arguments before they ran |\n| `info` *(default)* | the above, plus every tool call, request and response |\n| `debug` | the above, plus full payloads and every JSON-RPC message |\n\nYour session token is never written to the log, at any level.\n\n## 💻 Popular MCP clients\n\n> For a complete list of MCP clients and their feature support, visit the [official MCP clients page](https://modelcontextprotocol.io/clients).\n\n| Client                                                                                                         | Description |\n|----------------------------------------------------------------------------------------------------------------|-------------|\n| [Claude Desktop](https://claude.ai/download)                                                                   | Desktop application for Claude AI |\n| [Cursor](https://www.cursor.com/)                                                                              | AI-first code editor |\n| [Cline for VS Code](https://github.com/cline/cline)                                                            | VS Code extension for AI assistance |\n| [GitHub Copilot MCP](https://github.com/VikashLoomba/copilot-mcp)                                              | VS Code extension for GitHub Copilot MCP integration |\n| [Windsurf](https://windsurf.com/editor)                                                                        | AI-powered code editor and development environment |\n\n## 🆘 Support\n\n- For issues with this MCP Server: Open an issue on [GitHub](https://github.com/marcomoauro/substack-mcp/issues)\n",
  "bytes": 32833,
  "sha": "6e4b56ea00c3158b97af5be9b3ac061db1c782df155768afe56f61f606ae1a15",
  "repo_slug": "marcomoauro/substack-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_marcomoauro_substack_mcp_843b4f69/readme"
}