substack-mcp
marcomoauro/substack-mcp/CLAUDE.md
MCP server exposing Substack automation to LLM clients. ESM, npm. Development, CI and the image run Node 24 — .nvmrc and Dockerfile pin 24.19.0 exactly — while engines declares =22, so src/ may use nothing newer than Node 22 offers. That floor is deliberate, not inherited: 22 is the oldest Node line still receiving security patches (18 went EOL 2025-04-30, 20 on 2026-04-30), and it is the lowest version every production dependency accepts — @hono/node-server asks for >=20, everything else…
CLAUDE.md72 starsChanged 33 days ago
- Installs packages
# CLAUDE.md
MCP server exposing Substack automation to LLM clients. ESM, npm. Development, CI and the image
run **Node 24** — `.nvmrc` and `Dockerfile` pin `24.19.0` exactly — while `engines` declares
`>=22`, so `src/` may use nothing newer than Node 22 offers.
That floor is deliberate, not inherited: 22 is the oldest Node line still receiving security
patches (18 went EOL 2025-04-30, 20 on 2026-04-30), and it is the lowest version every
production dependency accepts — `@hono/node-server` asks for `>=20`, everything else `>=18`.
Raising it breaks anyone running `npx substack-mcp@latest` on an older runtime, so it is a major
bump; the honest ceiling is the oldest supported LTS, not the version that happens to be
installed locally.
**Two CI jobs run the suite: one on `.nvmrc`, one on the floor.** The floor job derives its
version from `engines` (`node -p "require('./package.json').engines.node.match(/\d+/)[0]"`)
rather than repeating it, so the promise and the test cannot drift. Developing two majors above
the floor is precisely how an unexercised `engines` rots into a lie — the runtime differences
are real and silent, as the `fetch` stack below shows.
## Language
**Everything in the repository is written in English** — source, comments, test names,
commit messages, PR titles and descriptions, docs. This holds regardless of the language
used in the chat: do not mirror the conversation language into the codebase.
## Commands
| Command | Purpose |
|---|---|
| `npm ci` | Install from `package-lock.json` (never `npm install` in CI or Docker) |
| `npm test` | Run the suite (`node --test 'src/**/*.spec.js'`) |
| `npm run test:watch` | Same, in watch mode |
| `npm run test:coverage` | Coverage report, spec files excluded |
| `npm pack --dry-run` | Verify what ships to npm |
`npm test` runs in under a second, but **its output shape depends on the Node version**: on 24 it
is the spec reporter (tally `ℹ pass`, failures marked `✖`), on the 22 floor it is TAP — five times
noisier, tally `# pass`, failures `not ok`. Grep for both, or a perfectly green run on the wrong
version comes back empty and reads as a broken command: `grep -E '^(#|ℹ) (tests|pass|fail)'` for
the tally, `grep -E '^(not ok|✖)'` for what broke.
## Distribution and releases
Release tags publish three artifacts: the npm package through `.github/workflows/npm-publish.yml`,
the multi-architecture Docker image through `.github/workflows/docker-build-push.yml`, and metadata
for both installation methods to the official MCP Registry. The registry job is separate from the
npm job so a registry failure can be rerun without attempting to publish the same immutable npm
version twice. It waits for the independently built versioned Docker image before publishing the
combined metadata.
`package.json`'s `mcpName` and the Dockerfile label
`io.modelcontextprotocol.server.name` are ownership proofs. Both must equal
`io.github.marcomoauro/substack-mcp`; removing either makes the corresponding package fail registry
publication. `server.json` is the source metadata, but its checked-in version is not a second release
version to bump by hand: the registry job derives the top-level version, the npm package version and
the `v<version>` Docker identifier from `package.json` before publishing.
## Layout
- `src/index.js` — entrypoint only: env check, `createServer()`, stdio transport. Keep it thin.
- `src/server.js` — `createServer()` factory plus the `tools` registry. **No side effects at
import time**: no env reads, no transport connection. Tests depend on this.
- `src/tools/<name>.js` — one file per MCP tool, exporting a zod schema and a handler.
- `src/api/substack/` — `SubstackApi` (HTTP), `SubstackPost` (ProseMirror document builder),
`SubscriberQuery` (the subscriber filter DSL) and `image.js` (the guarded fetch of a
caller-chosen URL, shared by `upload_image` and `update_draft`'s `cover_image`).
- `src/logger.js` — the only place that writes a log line. No dependencies, no state.
- `test/helpers/` — shared test helpers only; no tests live here.
Adding a tool means adding a file under `src/tools/` and one entry to the `tools` registry in
`src/server.js` — nothing else. `createServer()` loops over the registry calling
`McpServer.registerTool`, and the SDK derives `tools/list`, argument validation and dispatch
from what was registered, so there is no second place to update and nothing that can drift.
Do not add `setRequestHandler` calls for `tools/list` or `tools/call`: registering a tool
already covers both, and the SDK guards the clash rather than letting it slide — it throws
`A request handler for tools/call already exists, which would be overridden`.
## Substack's private API
There is no public documentation for any of this. Every endpoint below was read off the publisher
dashboard's own traffic and its `reactPublish.*.js` bundle, then confirmed against the live API —
so **check behaviour against a real request before trusting a claim about it**, including the
claims here. An official, key-authenticated Publisher API exists (`publisher_api_enabled`,
`/api/v1/publisher_api/api_key`) but is gated: the key endpoint answers **403** on a publication
that does not have it. If it ever opens up it is a better foundation than a session cookie.
**`POST /api/v1/subscriber-stats` is the whole subscriber surface** — list, filter, sort and search
in one call. `src/api/substack/SubscriberQuery.js` owns the translation, and the reasons it exists
rather than passing arguments straight through:
- A filter is a **flat key** inside `filters`: column name with an operator suffix glued on
(`num_comments_gt`). Multiple keys are ANDed. **No OR, no nesting** — an OR needs separate calls.
- **The suffix depends on the column's type**, and the API answers every mismatch with a bare 400
that names neither the column nor the alternatives. `is not` is `_not` on `subscription_type`,
`_distinct_from` on an Int or `group_membership`, and `_string_not` on a String. `_lte` is
Int-only; the DateTime equivalent is `_is_on_or_before`, not `_lte`. So the tool exposes operators
named by intent and derives the suffix, which makes an illegal pair unrepresentable rather than a
round trip away.
- **`search` goes inside `filters`.** At the top level it is ignored *silently* and the response
comes back unfiltered — which reads as "search matched everything", not as an error.
- **Sorting is two different keys**: `order_by` ascending, `order_by_desc_nulls_last` descending.
- **The API validates enum values too**, so `subscription_type: 'premium'` is a 400. The valid sets
are `paid|free|founding|comp|gift|free_trial|iap` and `none|member|admin`.
- `count` is the total matching the filters regardless of `limit`, which makes `limit: 1` a cheap
way to size a segment.
- **A request-level `columnView` is ignored.** The returned fields come from the publication's saved
Display settings, so this endpoint cannot be made to return the engagement columns. That does not
make them unreadable — the export below does read them — so `list_subscribers` points at
`export_subscribers` rather than telling the caller the data does not exist.
**The subscriber export is a four-step flow, and it is what makes every column readable.**
`src/tools/export_subscribers.js` owns it:
```
POST /api/v1/subscriber_set {query: <the same filters>} → {id}
POST /api/v1/subscriber_set/export {subscriberSetId, columns} → {export_id}
GET /api/v1/subscriber_set/export/<id> → {url} (poll)
GET <url> → CSV
```
Verified end to end. The dashboard's polling backoff is `1, 5, 10, 30`, then `60` repeated;
`EXPORT_POLL_BACKOFF_SECONDS` mirrors it, and the first poll happens immediately — which is free
now but was not always: see the pending state below. Five things this flow gets wrong if taken at
face value:
- **The pending poll is a `400`, not a 200 with an absent `url`.** While the file is generating,
`GET /subscriber_set/export/<id>` answers `400 {"error":"Export not ready","type":"single"}`;
a 200 without a `url` was **never observed at any point**. Measured 2026-09-03 at 250ms
intervals: ~3s of 400s on a *6-row* export, then `200 {"url": "…/file"}`. This file previously
claimed the opposite, and the cost of that was total: since the first poll is immediate, every
export aborted on its first request with a bare `400 Bad Request` (issue #25). Note what made it
invisible — the suite mocked the pending state as `200 {}`, so 748 tests stayed green while the
tool failed 100% of the time in production. **A mock of a state nobody has observed is an
assumption wearing a test's clothes.** `SubstackApi.getSubscriberSetExport` now translates that
one body into `{pending: true}` and rethrows every other 400, so the tool's loop is unchanged —
an absent `url` still means retry, which costs nothing and covers the state if it ever exists.
This is also why `readBody` attaches `status` and `body` to the error it throws: the message
carries only the status, so the body explaining the refusal was readable in the log and nowhere
else, and no caller could tell one 400 from another.
- **The CSV header carries human LABELS, not column keys** — `Emails opened (6mo)`, not
`num_email_opens`. `COLUMN_KEY_BY_LABEL` is the reverse map; it works because the labels are
unique, which `SubscriberQuery.spec.js` asserts by entry count so a future collision fails loudly
instead of dropping a column.
- **The server chooses the column order**, not the caller. Parse by header name, never by position.
- **One column cannot be exported and is dropped in silence:** `tag_ids`. Asking for all 48 returns
**47** with no error, so the tool diffs what it asked for against the header and reports
`missing_columns`. This is the same silent-drop hazard as `columnView`. This file said *two*
columns until 2026-09-03 and named `group_membership` as the second; measured that day, all 48
came back as 47 and `group_membership` carried the value `None`. Whether Substack changed or the
original reading was wrong cannot be told apart after the fact — which is why the tool diffs the
header instead of carrying a list of undeliverable columns, and why the count above is the part
to distrust first. The suite's CSV fixture omitted that header, so the error had a test agreeing
with it: the same shape as the pending-poll mock above.
- **The download url is relative to the publication host and cookie-authenticated** (403 without it,
not pre-signed) and answers **CSV, not JSON**. `handleResponse` unconditionally `JSON.parse`s, so
`readBody` was split out of it and `requestUrl({parse: 'text'})` is the raw path. `requestUrl` also
exists because that url cannot be appended to `publication_url`, which already ends in `/api/v1`.
`src/api/substack/csv.js` is a ~40-line parser rather than a dependency. `split(',')` is not enough
and fails *silently*: a subscriber named `Smith, John` shifts every later column of that one row.
**The analytics reports live in one registry**, `ANALYTICS_REPORTS` in `src/tools/get_analytics.js`:
16 verified endpoints under `/publication/stats/`, exposed as one tool with a `report` enum rather
than 16 near-identical tools, which would make a model's choice harder rather than easier. Each entry
carries its path, whether it takes a date window (`from_date`/`to_date`, or full ISO `start`/`end`
for retention), a default `limit` for the two that answer 400 without one, and the fixed extras the
dashboard always sends. **An unexpected parameter is a 400 on several of these**, so a parameter that
does not apply to the chosen report is dropped *and named* in `ignored_params` — the same
silent-drop hazard as `columnView` and the export's columns, which is now three for three.
**`email_stats` is misnamed and is not one of those reports.** Despite living under
`/publication/stats/` it is the **per-post table** behind the dashboard's "Posts" tab — which itself
lives at `/publish/stats/emails`, not `/publish/stats/posts` — with one row per post across the whole
archive: 43 fields covering delivery, opens, clicks, conversion (`signups`, `subscribes`,
`free_to_paid_upgrades`, `estimated_value`), churn (`unsubscribes`) and completion
(`subscribers_finished_post`). It takes `offset`/`limit`/`order_by`/`order_direction`, so it belongs
to `src/tools/get_post_stats.js` and is deliberately absent from `ANALYTICS_REPORTS`; both specs
assert the split, because two doors to the same data would mean choosing between a full report and a
crippled one. Two measured facts shape that tool:
- **`order_by` must be validated against the field list.** An unrecognised value answers **200** with
an arbitrary order, so a typo yields a ranking that looks authoritative. The enum is the only
guard — this is the *fourth* silent-ignore in this API.
- **There is no date filter.** `from_date`/`to_date` leave `total` unchanged at 863, so the schema
does not offer them; `strictObject` then tells a caller that guesses `from_date` that it is
unrecognised, which is the difference between knowing there is no window and believing there is.
Sorting works for real, though: `signups` descending returns 42, 41, 41, 27, 23… Ranking by a
*rate* puts `null` first, and the tool does not filter those out — that would answer a different
question than the one asked.
**Two endpoints next to them are broken upstream, not mis-called:**
`/publication/stats/audience_insights/location` (the subscriber map) and
`/publication/stats/visitor_sources` answer **400 even for Substack's own dashboard** — observed in
the page's own network log, with and without parameters. They are deliberately absent from the
registry, and `get_analytics.spec.js` asserts they stay absent. Do not "fix" them by guessing params.
**`GET /api/v1/post_management/{drafts,published,scheduled}`** lists posts. `order_by` is **not
optional**: `scheduled` answers 400 without it, which is why `src/tools/list_posts.js` keeps a
default per status rather than letting the server choose. Free-text search is `query`, and a null
one must never be serialized — `query=null` searches for the literal string. `GET /api/v1/drafts/:id`
returns a single draft whole.
**The draft lifecycle is four verbs on one path.** `POST /drafts`, `PUT /drafts/:id`,
`POST /drafts/:id/publish`, `DELETE /drafts/:id` — all verified except publish, which cannot be
confirmed without making something public. Two of the four carry a trap of their own:
- **`DELETE` is shared with published posts** and removes a live post just as readily, so
`delete_draft` spends a read to refuse an `is_published` target rather than exposing that reach
behind a draft-shaped name.
- **`PUT` is genuinely partial** — a body carrying only `draft_title` changed that and preserved the
body — so an absent key must never be sent as null.
**Publishing: the email flag is on the draft, not only in the request.** The bundle builds the call as
`post('/api/v1/drafts/' + id + '/publish').send({send: true, only_send: true})` — so the path and the
`send` key are real, and `only_send` means "email an already-published post without republishing it".
Two traps around it:
- **`share_automatically` does not exist.** It appears **zero** times in the bundle; the fork sent it.
An unexpected parameter is a 400 on several of this API's endpoints, so an invented key is not free.
- **`should_send_email` on the draft is where the dashboard keeps the decision** — its serializer
computes `dontSendEmail: !!email_sent_at || !should_send_email` — and it is **`true` by default** on
a real draft. Whether the initial publish honours a body `send` or reads that field cannot be
determined without publishing, and the ambiguity is dangerous in one direction only: a body `send`
that turns out to be ignored mails the entire list. `publish_draft` therefore PUTs the intent to the
draft *first* and passes `send` as well, so both possible behaviours agree. A failing PUT aborts —
publishing after the intent failed to save is the scenario the write exists to prevent.
**The draft's Post settings panel is nine writable fields, and six of its neighbours are lies.**
Verified 2026-08-08, each by a single-key `PUT /api/v1/drafts/:id` read back with a `GET`:
`audience` (`everyone|only_paid|only_free|founding` — the panel offers the first three, the API takes
`founding` too), `write_comment_permissions` (`everyone|subscribers|only_paid|none`),
`default_comment_sort` (`best_first|most_recent_first|oldest_first`), `cover_image`, `social_title`,
`description` (the social preview's, *not* the subtitle), `search_engine_title`,
`search_engine_description` and `slug`. The panel's DOM is the source for the enums — its controls
are real inputs carrying `name`/`value` — but the UI names are not the wire names: `commentLevel` is
`write_comment_permissions`, `commentSort` is `default_comment_sort`.
Three traps, all of them this API's signature move:
- **Six fields answer 200 and change nothing:** `postSchedules`, `language`, `email_from_name`,
`is_draft_hidden`, `ai_detection_disabled`, `free_unlock_required`. That is the **seventh** distinct
silent-ignore here, and `postSchedules` is the dangerous one — scheduling looks settable through
the draft and is not, so it needs an endpoint this server does not yet have.
- **The validation is asymmetric, and it fails worst where it matters most.** A bad `audience`,
`default_comment_sort` or `meter_type` answers 400 *naming* the parameter and its value; a bad
`draft_section_id` answers 400 `"Section not found"`. But a bad `write_comment_permissions` answers
`{"error":"Something went wrong","type":"single"}` — no field, no valid set. Its zod enum is the
only diagnosis a caller will ever get, which is why it is an enum and not a string.
- **`cover_image` is not validated at all.** The literal string `"not-a-url-at-all"` was accepted
with a 200 and stored, and so was an external Wikimedia URL. Substack will never say that a cover
cannot render — the same failure the fork's external `logo_url`/`photo_url` hit. `update_draft`
therefore forwards a URL on `substack-post-media.s3.amazonaws.com` or `substackcdn.com` unchanged
and re-hosts anything else through `POST /api/v1/image` first, since Substack server-fetches only
its own bucket. The re-host runs *before* the PUT so a download failure cannot leave the other
eight fields written.
`meter_type` takes only `none` and `metered`; every other value 400s. It, `should_send_email`,
`should_send_free_preview`, `explicit`, `hide_from_feed`, `show_guest_bios`,
`exempt_from_archive_paywall` and `podcast_description` are all writable and all deliberately
unexposed — the panel is the boundary. `should_send_email` especially: `publish_draft` already writes
it as the publish intent, and a second door onto the one flag that can mail the whole list buys
convenience at the cost of the irreversible.
**There are two hosts, and they are not interchangeable.** The publisher surface is on the
publication and keyed by publication id; *your profile, your subscriptions, the reader inbox, the
Notes feed and comment threads* are on `substack.com/api/v1` and keyed by **user** id. Each answers
404 for the other's paths, which is why `SubstackApi.requestGlobal` exists as a sibling of `request`
rather than callers concatenating a base — the choice of host is part of the endpoint.
**The tag surface is `/publication/post-tag`, and `/post_tags` is a 404.** GET lists and POST creates
on the same path; `/post-tag/:uuid` is one tag and `/post-tag/settings` adds `navigationBarItem`.
Three measured traps:
- **Tag ids are UUIDs**, the only ids in this API that are not integers. A schema typed `z.number()`
fails on contact.
- **`GET /post/:postId/tag` answers join rows** — `{id, publication_id, post_id, post_tag_id}` — with
**no name and no slug**, and neither `GET /drafts/:id` nor `post_management/*` carries tags at all.
So reading a post's tags means joining against the tag list; handed over raw it is a list of UUIDs
the caller cannot interpret. Note `id` there is the *association's* own UUID, not the tag's.
- **Attaching a tag twice answers 400**, naming neither the post nor the tag, and the count does not
change. `add_tag_to_post` checks first so the answer is `already_tagged` rather than a bare 400.
**Comments and Notes are the same entity**, which is why `src/api/substack/comment.js` serves both:
a Note is a comment with `type: 'feed'` and no `post_id`. Three of its fields are wrong by analogy
with the rest of this API, and were wrong in the fork this was ported from:
- The author is **flat** — `name`, `handle`, `photo_url` on the comment. There is no nested `user`.
- Replies are **`children_count`**. There is no `children` array to take `.length` of.
- There is **no `parent_id`**. Hierarchy is `ancestor_path`, a **dot-separated** chain of ancestor
ids, root-first, empty at the top — verified at three depths: `''`, `'309007328'`,
`'309007328.309403526'`. The parent is therefore the **last** segment. Reading the first names the
thread root as every nested reply's parent, which is wrong only once a thread is three deep and
silently correct before that.
`/post/:id/comments` answers `{comments, automod_hidden_comments}`; the second array is what automod
withheld and never appears in the first, so merging or dropping it turns "held" into "nobody
commented".
**There is a *second* comment shape, and it is the one you get back from writing.** `POST
/post/:id/comment` answers with the comment directly — no envelope — but carrying `children` (an
array) and `reactions` (an object) instead of `children_count` and `reaction_count`. Both paths run
through the same `summarizeComment`, so it reads the counts and falls back to the collections.
Verified alongside it: **`DELETE /api/v1/comment/:id` answers 200** and the comment leaves the post,
which is why `comment_on_post` is a write this server is willing to make.
**A restack, by contrast, cannot be undone.** `POST /restack/feed` is real and verified, and three
things about it were measured the hard way:
- **The body keys are camelCase** — `commentId`, `tabId` — unlike almost everything else here.
Confirmed by elimination: `{post_id, tab_id}` answers `400 "Devi fornire postId o commentId"`, the
API naming the keys it wanted.
- **Restacking a post does not work through it.** `{postId, tabId}` answers
`404 "Post da Restack non trovato"` for a published post on the caller's own publication, so
whatever that path needs is not an id from `list_posts`. `restack_item` therefore takes only a Note.
- **A restack has no id of its own.** It surfaces the *original* Note with `context: comment_restack`
in the profile feed, so `DELETE /comment/:id` would target someone else's Note rather than the
restack. Un-restacking is a UI toggle with no endpoint found in a full sweep of all 109 scripts on
`substack.com` — `restack/feed` appears there exactly once, and only as the create path.
**Three endpoints return heterogeneous arrays, and only some entries carry content.** This is the
same silent-drop family as `columnView` and the export's columns, seen from the other side — here the
hazard is mapping straight through and *inventing* empty entries:
- `/subscriptions/all/v2` → `subscription`, `label` (a section header like "Paid"), `add_more`.
- `/reader/feed` and `/reader/feed/profile/:userId` → `comment` (a Note), `post`, `userSuggestions`.
Filter on the type, not on whether a field happens to be present: today's `label` has no `pub`, so a
presence check appears to work and stops working the moment Substack adds a type that carries one.
**Four more facts about the reader surface, each measured:**
- **`paused` is `null`, not `false`**, when a subscription is not paused, and a *free* subscription
carries an `expiry` in the year 2121. So `paused === false` drops every active subscription, and a
present expiry is not evidence of a paid term.
- **`/reader/posts` pages by `after`, a timestamp**, taken from the last `inboxItems` entry's
`content_date`. Its own top-level `cursor` is always null.
- **Feed tab `name`s are localized** — they came back in Italian — so a tab is selected by `id`.
- **The Inbox and the feed attach `body_html` and `body_json` to every post.** An unprojected page of
20 runs to hundreds of KB. Listings carry `truncated_body_text`; `/posts/by-id/:id` is how to read
one in full, and it is the only endpoint that returns another publication's body.
`get_reader_post` leaves that body as **HTML** rather than converting it. Markdown would mean a new
dependency or a regex pass over markup, and a regex HTML converter mangles nested lists and embeds
*silently* — the same argument as `csv.js` reaching the opposite conclusion, because HTML is not a
bounded grammar and CSV is. An LLM reads HTML perfectly well.
**Deliberately not implemented, and why**, so it is not re-litigated: `create_note` and
`reply_to_note` need `playwright-extra` plus a stealth plugin to obtain a `cf_clearance` cookie and
get past Cloudflare bot management on `POST /comment/feed`. That is three heavy dependencies, a
browser download, a broken Docker image, and a technique that breaks on Cloudflare's next change.
Also skipped: `update_payment_settings` (paywall pricing from an LLM), deleting *published* posts,
and a hand-maintained `list_resources` catalog — a second tool list that can drift from `tools/list`.
**The forks are exhausted, and a re-survey costs more than it returns.** All eight were compared
against `main` again on 2026-08-07, after #18: five are zero-commit snapshots, `adamhwoodworth`'s
`fix-draft-body-double-encoding` is already merged as #4 (its two residual commits carry a `parseBody`
test this repo has in English *plus* the logging assertions), and `jcllobet`'s reader work went in
with #18. Only `jefflee1990710` still holds anything, and it is one coherent area rather than a list:
**account and publication settings** — `PUT /api/v1/publication` (`name`, `hero_text`, `copyright`,
`email_from_name`, `logo_url`) and `PUT substack.com/api/v1/user/profile` (`name`, `bio`, `photo_url`).
Per the fork these store an external `logo_url`/`photo_url` that does not render, so they likely need a
Substack-hosted asset first — which `upload_image` now produces (`POST /api/v1/image`, verified and
shipped; see the image contract below). Both settings writes are still unverified writes from the fork
that invented `share_automatically`, so neither ships without a live check first; they are open by
choice, not by oversight.
**Writes log their intent at `info` *before* the request**, not only their outcome —
`publish_draft.publishing`, `comment_on_post.posting`, `restack_item.restacking`, with the full text
where there is one. Nothing in this server can unpublish, delete a comment or undo a restack, so that
line is the only record that it happened.
**The post body has a contract, and `set_post_body` is where it lives.** `src/api/substack/document.js`
models the ProseMirror document in zod — a discriminated union over the fifteen node types observed in
the live archive, published as that tool's JSON Schema so a calling model reads the vocabulary from
`tools/list` instead of guessing. It is the only tool that publishes it: at roughly 21 KB, carrying it on
`create_draft_post` and `update_draft` too would more than double a `tools/list` that is 56 KB with one
copy. `create_draft_post`'s JSON branch runs the **same validator without publishing it**, which cost
nothing and closed the last route that accepted a body unchecked. Six measured facts shape it:
- **The code block is `highlighted_code_block`.** `python-substack` declares `codeBlock`, which is not
what the editor writes and does not render. Both that and the legacy `code_block` are accepted —
16 occurrences against 5 in the survey, so refusing the old one would refuse those posts.
- **`order` numbers a list, not `start`.** A list given `{start: 3}` is stored verbatim, answers 200 and
renders from **1**; `{order: 3}` renders from 3. The editor writes both, so reading its own output
suggests either would do — this is the **sixth** silent-ignore in this API and the only one this
server nearly authored itself.
- **`type` is strict, `attrs` are loose**, in opposite directions on purpose. The editor writes
`textAlign: null` on every block and `nodeId: null` on code blocks, so strict attrs would reject every
real post; the discriminated union on `type` is what produces `Invalid discriminator value. Expected
'paragraph' | 'heading' | …`, the only repair instruction an LLM gets. **A generic unknown-node branch
was tried and rejected**: it keeps a malformed `heading` out but reports only the generic branch's
error, so the caller never learns which field is wrong. Every observed type is enumerated instead,
including three whose internals were never read — `digestPostEmbed`, `substack_mentions`,
`directMessage` — as `looseObject`s that survive a round trip whole. `digestPostEmbed` alone is in 59
of 60 sampled posts, so this is what makes read-modify-write possible at all.
- **Survey both publications before trusting the enumeration.** The first pass covered `implementing`
only and missed `youtube2`, which is in 33 of 40 sampled `quickviewai` posts — `{videoId}`, no content.
22 real posts across both now validate with no unknown node, no unknown mark and every required attr
present.
- **A document may carry one `paywall`, and we are the only ones enforcing it.** Two are accepted with a
200 and rendered as two "Paid content below this line" markers, leaving it undefined which one cuts
the post. Since a `.refine()` does **not** survive into the published JSON Schema, the rule is repeated
in the node's description or a model meets it only by failing.
- **The code-block `language` is a closed set that fails silently.** `auto` is the auto-detect sentinel,
`plaintext` the plain-text value, and an unrecognised name renders as Plain Text with no error, so
omitting the attr beats guessing.
**Images can be uploaded after all, and `upload_image` is how** — this once read "cannot be uploaded,
`POST /api/v1/image` hangs in all three encodings, do not implement." That record was wrong. Re-measured
live 2026-08-08 on `implementing.substack.com` from the authenticated dashboard: the endpoint answers
**200 in ~300ms**. The three earlier attempts (JSON, form-urlencoded, multipart) failed because they sent
the wrong *thing*, not for a header detail or a Cloudflare wall — the body is JSON `{image:
"data:<mime>;base64,…"}`, a **data URI**, built in the editor from `canvas.toDataURL()`. The response is
`{id, url, contentType, bytes, imageWidth, imageHeight}`, `url` on `substack-post-media.s3.amazonaws.com`
— the host every `image2.src` uses — and it renders through Substack's CDN (proven end to end on a real
draft: upload → `captionedImage` → PUT → the editor shows a `substackcdn.com/image/fetch/…` render).
Three measured facts shape the tool:
- **Substack server-fetches only its own S3 bucket.** An external URL passed as `image` answers
`400 "Failed to fetch image"`, so `upload_image` downloads the URL itself and re-encodes it. That
download is where this server fetches a caller-chosen host, so it is guarded: `http(s)` only,
an SSRF block on private/loopback/link-local addresses *after* DNS resolution and re-checked on every
redirect hop, an `image/*` content-type check (HEIC refused early, as the dashboard does), and a 10 MB
cap that is **ours, not Substack's** — the bundle's `MAX_FILE_SIZE` could not be read from the minified
source. The pipeline lives in `src/api/substack/image.js` rather than in the tool, because
`update_draft`'s `cover_image` re-hosts through the identical path — a second copy of an SSRF guard
is a second place to get it wrong. Its errors are prefixed `image:`, not `upload_image:`, so a
`cover_image` failure is not signed by a tool the caller never invoked.
- **The data URI is elided in the logger, not just kept out of the tool's own lines.** `SubstackApi` logs
every request body at info, so a real upload would put hundreds of KB of base64 on one line;
`src/logger.js` truncates a `data:…;base64,` value to its prefix and omitted length. A post body, being
prose, is still logged in full — the two are different in kind.
- **A local file is the other source, and nothing about the endpoint had to change to accept one.**
`upload_image` takes `path` as the alternative to `url` (`readImageFileAsDataUri`, the sibling of
`fetchImageAsDataUri` in the same module); the endpoint only ever wanted a data URI, so where the bytes
came from was never its business. Verified live 2026-08-09 with a real JPEG and PNG off disk — both
answered 200 with the S3 url and the true `1500x1000`. Three things this branch does *not* inherit
from the URL one, each deliberate:
- **The type comes from the magic bytes, never the extension.** A local file carries no
`Content-Type`, and the extension is the caller's claim rather than evidence: a PDF renamed `.png`
would otherwise reach Substack and come back as a 400 naming neither the file nor the reason. PNG,
JPEG, GIF and WebP are recognised; an ISO-BMFF `ftyp` box with a HEIC/HEIF brand gets the same
convert-first message the URL path gives. Written out rather than taken as a dependency — five
bounded signatures, the same argument `csv.js` makes. **SVG is refused** by that check
(`3c 73 76 67`), and whether `POST /api/v1/image` would accept `image/svg+xml` was never measured:
the dashboard builds its uploads from `canvas.toDataURL()`, which cannot produce one.
- **The path must be absolute.** A relative one resolves against *this server's* cwd, not the calling
client's, so it would read the wrong file or none — and neither failure would say why. `realpath`
runs before the checks, so a symlink is followed to the file actually read and `..` cannot mean one
thing at the check and another at the read.
- **The size cap is enforced on `stat.size` before the file is read**, the local mirror of the
`Content-Length` pre-check in `readCapped` — and only that half of it, deliberately. `readCapped`
re-checks the buffered length afterwards because `Content-Length` is a claim by an untrusted
remote server; `stat.size` is the kernel's answer about the file `realpath` just resolved, with
no second party to disagree. The window between the `stat` and the `readFile` is an accepted
residual, on the same terms as the DNS-rebinding note: the adversary it would buy protection from
already has write access to the disk.
Adding the second source meant editing **three** descriptions, and the third was nearly missed: the
two property descriptions in the schema, *and* the tool's own `description` in the `tools` registry.
That last one is what a model reads to choose **which** tool to call — the property descriptions are
only reached after it has already chosen — so a pitch that still said "from an http(s) URL" left
`path` present in the schema and invisible in the offer. That is exactly how a model concludes a
local file cannot be uploaded and reaches for the browser instead. `server.spec.js` now asserts the
pitch names both sources.
There is **no filesystem allowlist** — any absolute path the caller names is read. That was a
deliberate call, not an oversight: the env-var root was designed and dropped because a server that
needs configuring before `path` works at all is a server back where it started. Note what it means,
though — the caller is an LLM, so this is a read of an arbitrary local file whose bytes then leave for
Substack. `update_draft`'s `cover_image` deliberately does **not** take a path: the flow is
`upload_image({path})` → the S3 url → `update_draft({cover_image: <that url>})`, which recognises the
Substack host and writes it unchanged. Two doors onto one thing would be one too many.
**The header token is verified now, and this is what closed it.** Every earlier live check ran on the
browser session cookie, leaving `SUBSTACK_SESSION_TOKEN` in a header through `SubstackApi` as the
untested path. On 2026-08-08 a scratch draft was driven end to end through the real handlers with that
env var: `create_draft_post` → `update_draft` with all nine settings *and* an external
`upload.wikimedia.org` cover → `get_draft` → `delete_draft`. All eleven fields read back exactly as
sent, and the cover came back on `substack-post-media.s3.amazonaws.com`, so the download, the
`POST /api/v1/image` re-host and the PUT all authenticate the same way the cookie does. Nothing in the
token path is speculative any more.
**`set_post_body` returns a node tally, not `'OK'`**, because validation cannot report what was never
sent: a document with no paywall is exactly as valid as one with a paywall. This was measured — a model
asked for a paywall through a Markdown contract omitted it, produced valid Markdown, and the document it
rendered to passes this very schema. The tally is the caller's only way to see what landed.
**The descriptions are load-bearing and a test enforces them.** The published schema is the vocabulary,
so `document.spec.js` walks the whole converted schema and fails on any union branch without a
`description`, naming it. The walk is recursive on purpose: `list_item`, `image2` and `caption` are
reachable only nested, and an earlier top-level-only version left stripping every mark description green.
## Logging
**Everything must be logged well enough to debug a session from the log alone**, because the
caller is an LLM and the failure is usually in what it sent, not in this code. Every new tool,
method or branch that a call can take gets a line: the arguments it received, the decision it
made, the outcome. `src/logger.js` is the only writer — never `console.log`.
- **stdout belongs to the JSON-RPC transport.** One byte written there corrupts the protocol
and the client disconnects, which is why the logger writes to stderr and why
`src/index.spec.js` asserts that stdout parses as JSON-RPC and stderr as log lines. MCP hosts
collect the server's stderr into their own log files; that is where these lines get read.
- One JSON object per line: `{"ts","level","msg",…fields}`. `msg` is a dotted event name
(`tool.call.start`, `substack.response`), not a sentence — it is what you grep for.
- Levels are `silent < error < warn < info < debug`, from `SUBSTACK_MCP_LOG_LEVEL`, default
`info`, read **at call time** (nothing in `src/` may read the environment at import time).
`info` is the story of a call — tool in, request out, response, result. `debug` adds the
payloads and the document-building steps. An unknown value falls back to `info` rather than
muting the server.
- **Secrets are redacted by key name**, recursively: `/token|cookie|password|secret|auth|session|^sid$/i`
becomes `***`. So pass whole objects (`{headers}`, `{args}`, `{draft}`) and let the logger
handle it, rather than picking fields by hand at each call site. Post content is *not*
truncated — it is usually the thing being debugged.
- **Two carve-outs keep the pattern from eating the diagnosis.** `sid` is anchored (`^sid$`)
because as a substring it also matches `considerations`. And a **boolean** under a secret key
survives: one bit cannot leak a credential, while redacting it turns the useful part of the
line into `***` — `has_auth_token: Boolean(auth_token)` logged as `"***"`, stating only that
the field exists, is a bug this repo has already shipped once.
- The logger never throws: `Error` values are expanded to `{name, message, stack, cause?}` (raw,
they serialize to `{}`), cycles become `[Circular]`, and an unserializable payload degrades to
a `log_error` note.
- **`cause` is expanded recursively, and on Node 24 it is the whole diagnosis.** Native `fetch`
rejects with `TypeError: fetch failed` whose stack has **no frames at all** — Node 22 still
attached the caller's async frames, which is why the Node 24 bump turned one entrypoint test
red. Everything actionable hangs off `.cause`, so `redact` follows the chain (through the same
redaction as any payload, since a cause is not a trusted container). Logging only
`{name, message, stack}` there yields a line stating that something failed and nothing about
what.
- **A rejected tool call cannot be logged from inside the handler.** `McpServer` validates
arguments itself and answers `Input validation error` before the handler runs, so
`logOutgoingMessages(transport)` wraps `transport.send` to catch it — `warn` for anything
carrying `error` or `isError`, `debug` for the rest of the traffic. That line is the most
useful one in the file when a model cannot get a call right.
- Tool failures are logged **and rethrown**: `McpServer` still converts them into an `isError`
result. Swallowing one would change the protocol behaviour.
- `setTestEnv()` forces `SUBSTACK_MCP_LOG_LEVEL=silent`. **Call it from every spec whose subject
logs**, including one that reads no env var of its own — `SubstackPost.spec.js` needs it purely
for this, and the two `src/api/substack` suites once printed 14 log lines over the reporter for
want of it. Assert on logs with `test/helpers/capture-logs.js`, which sets the level and
captures stderr for the duration of a call.
## Testing
Tests are colocated with their subject as `<name>.spec.js` (e.g. `SubstackApi.spec.js` sits
next to `SubstackApi.js`). They are excluded from the npm tarball via the `files` negation
pattern and from the Docker image via `.dockerignore` — update both if the naming changes.
**All outbound HTTP is mocked with MSW**, configured with `onUnhandledRequest: 'error'` so an
unmocked request fails the test instead of reaching the network. Never disable that. Build
overrides with `msw.draftsHandler(...)` rather than a bare `http.post`, otherwise the request
is not recorded in `msw.requests`.
The exception is `src/index.spec.js`, which spawns the entrypoint as a child process: MSW runs
in the test process and cannot intercept anything there. To exercise a failing request, point
`SUBSTACK_PUBLICATION_URL` at `http://127.0.0.1:1` — the request gets logged, then fails without
a packet leaving the machine. Port 1 is on the fetch spec's blocked-port list, so it never even
reaches a connect: the rejection is `TypeError: fetch failed` with `cause.message` of
`bad port`, not the ECONNREFUSED this file used to claim. Assert on the *shape* of that failure
(a cause with frames), not on either message — both are runtime detail.
Tests that pin *current* behaviour rather than desired behaviour carry a `CHARACTERIZATION`
comment explaining why. If one fails, suspect the test before the source — that is the point
of it. Update the comment in the same commit that changes the behaviour.
**A new test that passes on the first run has proven nothing.** Break the source on purpose —
revert the fix, strip the field, loosen the schema — confirm it fails, restore. This caught
three tests that were passing vacuously: `additionalProperties` was dropped from the published
schema with nobody noticing, the schema test survived having every `description` stripped, and
"never writes the session token to the log" passed with redaction disabled, because the
handshake it drove never logs a request header in the first place — it now drives a real tool
call against a closed local port. Renaming the log line under test is the cheapest mutation for
a logging assertion. **Check the mutation actually landed** before trusting a green run: a
`sed`/`perl` regex that fails to match leaves the file untouched and reads exactly like a test
that asserts nothing. Grep the file for a marker first. The whole suite runs in well under a
second, so a mutation costs nothing.
**A fixture that represents an API *state* must cite where that state was observed** — a date, a
captured log line — and anything not observed has to say so. The mocks are written by us, so the
suite measures this repo's model of Substack and never Substack; a bug of the form "the API does
something we have not seen" is *structurally invisible* to it, however thorough it is. Issue #25
is the demonstration: the export's pending state was mocked as `200 {}`, the real state is a
`400`, and no assertion over that mock could have failed because it described the wrong universe.
Note which mocks are exposed. A happy-path fixture is corrected by real use sooner or later; a
fixture for a **transient or error** state is corrected by nobody, because nothing exercises it
until something exercises it constantly. Suspect those first.
**Verify a time-dependent flow by running the real handler, never by hand.** Polling, retry,
backoff, TTL and expiry all mean the code and a human occupy different timing regimes: hand-issued
requests land seconds apart, `export_subscribers` polls 200ms after asking for the file, and the
window it lands in is ~3s wide. A manual walk through the endpoints cannot enter that window and
the code cannot avoid it, so curl-by-curl "verified end to end" is not a verification of the same
program. The technique that works is already in this file — the token check drove
`create_draft_post` → `update_draft` → `get_draft` → `delete_draft` **through the real handlers**
with `SUBSTACK_MCP_LOG_LEVEL=debug` and read the log. Do that once per tool whose flow is
multi-step or timed, and keep the log: it is the only check that can contradict an assumption
rather than confirm it.
## Style
Two-space indent, semicolons, single quotes in code (imports use double), compact object
literals (`{a: 1}`, not `{ a: 1 }`).
## Gotchas
- **Dependencies are pinned exactly** — no `^` or `~` ranges anywhere. The project `.npmrc`
sets `save-exact=true` so `npm install <pkg>` keeps it that way; do not add ranges by hand.
- **npm 11 (bundled with Node 24) does not run dependency lifecycle scripts by default.** `npm ci`
prints an `allow-scripts` warning and skips msw's `postinstall`; the suite passes anyway,
because that script already swallows its own errors. Noise, not a failure — do not "fix" it by
approving scripts.
- **`node --test` does not discover `*.spec.js`** with its default patterns. The npm scripts
pass the glob `'src/**/*.spec.js'` explicitly; single quotes matter so Node expands it, not
the shell.
- **Check the SDK's own source before asserting how it behaves.** Nearly every gotcha below
depends on SDK internals, and it ships readable ESM in
`node_modules/@modelcontextprotocol/sdk/dist/esm/`: `server/mcp.js` (registration,
validation, published tool definition), `server/zod-json-schema-compat.js` (schema
generation), `shared/protocol.js` (handler registration). Reading it is how the
`{target: 'draft-7', io: 'input'}` options and the silent `zod-to-json-schema` failure were
found; assuming instead once put a false claim in this very file.
- **Tool failures are results, not rejections.** `McpServer` turns anything a tool throws —
a validation error, an unknown tool name, a `SubstackAPIException` — into a *successful*
`CallToolResult` with `isError: true` and the message in `content[0].text`. `client.callTool()`
does **not** reject, so `await client.callTool(...).catch(e => e)` hands you the *result*, not
an error: `assert.match(error.message, ...)` then dies with `The "string" argument must be of
type string` instead of a useful diff, while anything looser (`assert.ok(error)`) passes while
checking nothing. Assert `result.isError` and `result.content[0].text` instead.
(Protocol-level failures — an unknown *method*, a malformed request — are still `McpError`,
prefixed `MCP error -32601: ` and friends.)
- **`callTool` results are `JSON.stringify`-ed by the server**, so a handler returning `'OK'`
arrives as `text: '"OK"'` — quotes included.
- **`SubstackPost.getDraft()` calls `JSON.stringify` on `draft_body`**, so it must be handed an
object. Passing a string double-encodes it; this was a real bug (#4).
- **HTTP goes through native `fetch`** — no HTTP client dependency. `fetch` does not reject on
non-2xx, so `SubstackApi.handleResponse` is what turns a failing status into an error
(`SubstackAPIException: <status> <statusText>`); it also serializes the body and sets
`Content-Type` by hand, which axios used to do implicitly.
- **The SDK owns JSON Schema generation** — never convert a schema by hand. `registerTool`
publishes `inputSchema` itself, via `z.toJSONSchema(schema, {target: 'draft-7', io: 'input'})`
under the hood (`server/zod-json-schema-compat.js`). If you are ever tempted back to the
low-level `Server`, know that `zod-to-json-schema` returns a bare `{$schema}` for a zod 4
schema **without throwing**, publishing a parameterless tool to every client.
- **Tool schemas are `z.strictObject`, never `z.object`.** The validation message is the only
feedback an LLM gets to repair a malformed call, and a plain `z.object` *strips* unknown keys
silently: a model sending `content` instead of `body` is told only that `body` is missing,
never that its own key was ignored. `strictObject` adds `Unrecognized key: "content"`, and
makes the published `additionalProperties: false` truthful instead of an empty promise.
Prefer `z.strictObject({...})` over `.strict()` — zod 4 points at the former.
`src/server.spec.js` pins the wording of these messages on purpose: a degraded message
breaks nothing by itself, the call still returns, so only an explicit assertion catches it.
- **zod is not optional and not ours to drop.** It is a non-optional `peerDependency` of the
SDK, and `registerTool` throws `inputSchema must be a Zod schema or raw shape` for anything
else — the SDK's validation *is* zod. npm auto-installs it even if it leaves `package.json`,
so removing the direct dependency buys nothing and unpins the version.
- **`ZodError` details live on `.issues`, not `.errors`** (zod 4 renamed it). The readers in
`src/` are the `*.args.invalid` logs (`create_draft_post`, `upload_image`, and the other tools
that parse in a try/catch) — the SDK formats the message it sends to the client — and `.errors`
silently yields `undefined` rather than failing, so a handler that inspects them logs nothing and
reports no error.
## Verifying the server actually works
`src/index.spec.js` now automates this: it spawns the real entrypoint as a child process,
completes the handshake over stdio and asserts the env-var guard. It is the only coverage
`src/index.js` has, since the file does its work at import time.
Still drive it by hand when changing the transport or the protocol surface — the assertions
only check what they were told to check:
```bash
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| SUBSTACK_PUBLICATION_URL=https://test.substack.com SUBSTACK_SESSION_TOKEN=tok SUBSTACK_USER_ID=1 \
timeout 5 node src/index.js
```
Note the process exits 0 immediately when stdin is at EOF — that is normal, not a crash, so
exit status alone tells you nothing.
Diff this output before and after a refactor to prove the protocol is unchanged, but normalise
each line first or key-order churn swamps the real change — `$schema` merely moving position
reads as a diff:
```bash
diff <(python3 -m json.tool --sort-keys <<< "$(sed -n '2p' before.txt)") \
<(python3 -m json.tool --sort-keys <<< "$(sed -n '2p' after.txt)")
```
**Two runtimes, so verify at both ends of the range** when touching anything runtime-sensitive
(`fetch`, errors, streams). CI does this on every push; locally nvm is a shell function, so a
non-interactive shell has to source it first:
```bash
source ~/.nvm/nvm.sh && nvm exec --silent 22 npm test && nvm use && npm test
```
**The image is a shipped artifact the suite never touches.** After a base-image bump, confirm the
runtime is what you think and that the server still answers — the probe above works unchanged
with `docker run -i --rm -e … substack-mcp:check` in place of `node src/index.js`:
```bash
docker build -t substack-mcp:check . && docker run --rm --entrypoint node substack-mcp:check --version
```
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

