Command Palette

Search for a command to run...

UnylyUnyly
Весь каталог

Substack Draft Server

БесплатноНе проверен

Private MCP server for validating, previewing, creating, and updating Substack newsletter drafts through an MCP-compatible client.

GitHubEmbed

Описание

Private MCP server for validating, previewing, creating, and updating Substack newsletter drafts through an MCP-compatible client.

README

CI Documentation License

Open-source, self-hosted MCP server for validating, previewing, creating, and updating Substack newsletter drafts through an MCP-compatible client.

This project uses Substack's unofficial/internal API. It may break if Substack changes its editor or endpoints. Keep credentials private. Use this for personal drafting workflows only unless you add proper OAuth and complete a security review.

Safety Boundary

This project manages unpublished Substack drafts. It does not publish, schedule, delete, email, or create public Substack Notes. Users review and publish manually inside Substack.

What It Does

  • Exposes seven MCP tools for Substack draft workflows over Streamable HTTP or stdio: list_drafts, get_draft, validate_newsletter_content, preview_draft, create_draft, update_draft, and upload_image.
  • Converts Markdown or structured blocks into Substack's editor format, including native code blocks, display LaTeX, images, alt text, and captions.
  • Requires a matching preview_draft confirmation token before creating or updating a draft.
  • Accepts exact image artifacts, public image URLs, base64/data URIs, SVG, or generated cards and reports source and processed fingerprints.
  • Keeps Substack credentials server-side and redacts secrets and draft content from normal logs and client-visible errors.

Start with Quickstart, then follow the client setup guide for a stable ngrok URL, ChatGPT, Claude, Claude Desktop, or Claude Code. The published documentation is available on GitHub Pages.

Supported Formatting

The markdown_v1 path supports headings, paragraphs, bold, italic, links, inline code, blockquotes, ordered and unordered lists, horizontal rules, images, fenced code blocks, LaTeX blocks, and explicit :::image / :::latex directives. The blocks_v1 path supports structured newsletter blocks for callers that prefer JSON over Markdown.

Provide only the body field that matches body_format: body_markdown for markdown_v1, or blocks for blocks_v1. Mixed body payloads are rejected before preview-token generation.

Markdown and blocks_v1 URLs must be public http(s) URLs. Unsafe links and images are dropped or rejected before they can reach Substack.

Known Limitations

  • Substack's draft API and editor document shape are unofficial and may change without notice.
  • Images and native captions use the current captionedImage/image2 editor shape, but still require a real captured draft-body fixture and manual rendering review before their provisional mapping can be called complete. Native highlighted-code and LaTeX mappings have the same live-verification boundary.
  • Live hosted deployment, real OAuth login, and acceptance in third-party MCP clients remain manual V1 gates.
  • This server creates and updates unpublished drafts. Publishing, scheduling, deleting, emailing, and public Substack Notes are intentionally out of scope for V1.

Tool Flow

Use list_drafts to find recent draft IDs from metadata-only summaries and get_draft to fetch draft metadata. get_draft omits the draft body by default; pass include_body: true when body inspection is needed. The native draft_body response field is authoritative, with body used only as a compatibility fallback. When the selected body is recognized as native Substack JSON, the response also includes body_format: "substack_native_v1", a body_sha256, and a one-based images manifest. Requested draft bodies are checked against MAX_BODY_BYTES before they are returned.

Draft URLs returned by list_drafts, get_draft, create_draft, and update_draft are filtered before reaching MCP clients. URLs with unsupported protocols, embedded usernames/passwords, localhost hosts, or private-network hosts are omitted; write tools include a warning when they omit a candidate draft URL.

Use upload_image before referencing a new image in draft content. When the image was generated or uploaded in the current conversation, pass that exact artifact through image_file. Never redraw, recreate, simplify, or substitute another image merely to satisfy the schema. Use image_url only for an existing remote image and image_base64 only when neither file nor URL input is available. Existing URL and base64 calls remain compatible.

Legacy svg and card sources remain available only when the user explicitly requests that exact server-rendered source. They must never be used as a fallback for an unavailable generated or uploaded artifact.

image_file accepts either the complete file object supplied by an MCP client or an absolute mounted local path. Remote file objects must include a downloadable URL; opaque placeholders are rejected with SOURCE_ARTIFACT_UNAVAILABLE rather than being replaced. Local paths are disabled by default and must resolve inside one of the comma-separated IMAGE_FILE_ROOTS directories. The resolver uses the exact path, verifies real-path containment, and never searches neighboring files or selects the most recent image.

All accepted sources pass through decode, validation, auto-orientation, visual-fidelity comparison, sRGB PNG encoding, reopen verification, checksum, and upload. Source dimensions are preserved by default unless explicit bounds require resizing. Successful results include source and processed metadata with filename, detected format, MIME type, dimensions, color mode, byte size, and SHA-256; file inputs also include a source artifact identifier. preview_url is the exact uploaded URL. Structured warning_details report format conversion, resizing, aspect-ratio change, large size reduction, color-mode change, transparency removal, stripped metadata, and suspicious output; the legacy warnings string list remains for compatibility. allow_resize: false rejects a required resize, and unexpected aspect-ratio or color-to-monochrome changes fail before upload unless explicitly allowed.

Image operations are deliberately separate: upload_image uploads media only, preview_draft previews insertion or a targeted replacement and lists every exact image URL plus its dimensions, alt text, caption, and title, and create_draft or update_draft performs the confirmed draft write. alt_text is accessibility text. caption is visible newsletter text represented by the native Substack caption child; title is separate image metadata. Neither is inserted by upload_image alone.

To replace one image in an existing rich draft without reconstructing its body:

  1. Call upload_image and retain its exact uploaded image_url.
  2. Call get_draft with include_body: true, then select either the current image URL or the one-based native_index from draft.images.
  3. Call preview_draft with action: "update", the draft_id, and image_patch. Provide exactly one selector: match_image_url or image_index.
  4. Review the returned complete image manifest and patch hashes, then call update_draft with the identical draft_id, image_patch, and confirmation_token.
{
  "action": "update",
  "draft_id": 123,
  "image_patch": {
    "image_index": 2,
    "replacement_image_url": "https://substackcdn.com/image/fetch/...",
    "alt_text": "Architecture diagram",
    "caption": "Updated platform architecture"
  }
}

Omit alt_text, caption, title, width, or height to preserve its current value; pass null to remove it explicitly. Do not combine image_patch with body or metadata fields. The server fetches the authoritative native body, changes exactly one image2 node, preserves all other JSON fields, and binds the patched body to the confirmation token. If the body changes between preview and the update check, the token is rejected as stale. Caption changes require an existing native captionedImage container; the server never inserts a paragraph fallback.

Exact local file source:

{
  "source_type": "file",
  "image_file": "/absolute/allowlisted/path/architecture-diagram.png",
  "alt_text": "Architecture diagram for the multi-tenant platform",
  "caption": "Multi-tenant AI platform reference architecture."
}

SVG source:

{
  "source_type": "svg",
  "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"800\" height=\"400\"><rect width=\"800\" height=\"400\" fill=\"#ffffff\"/><text x=\"40\" y=\"210\" font-size=\"48\">Hello</text></svg>",
  "filename_hint": "hello.png"
}

Generated card source:

{
  "source_type": "card",
  "card": {
    "width": 1200,
    "height": 630,
    "title": "Scaled dot-product attention",
    "subtitle": "A visual guide",
    "footer": "The AI Edge"
  },
  "alt_text": "Article title card"
}

Remote URLs must be public http(s) URLs without embedded credentials. The tool rejects private or localhost hosts and private DNS resolutions, does not follow redirects, bounds downloads with SUBSTACK_REQUEST_TIMEOUT_MS, and enforces MAX_IMAGE_BYTES against declared, streamed, and normalized bytes. SVG input is parsed as untrusted data and rejects scripts, event handlers, external references, foreign content, entities, processing instructions, and unsafe CSS. Local image_file paths require the explicit IMAGE_FILE_ROOTS allowlist and are intended for local or mounted stdio/server deployments; they do not grant a remote server access to files on the caller's machine.

Use validate_newsletter_content to check Markdown or blocks_v1 content without generating a write token. Markdown links and image/directive URLs must be public http(s) URLs to become clickable or image nodes; unsafe Markdown URLs are reported as unsupported features. Explicit blocks_v1 href, src, and url fields fail validation unless they are public http(s) URLs.

For agent-generated Markdown, standalone directives are supported when explicit structure is more reliable than Markdown shorthand:

:::image
src: https://substackcdn.com/image/fetch/...
alt: Architecture diagram
caption: Local and hosted MCP paths
:::

:::latex
\int_0^1 x^2\,dx = \frac{1}{3}
:::

A fenced block whose language is exactly latex is also treated as display math rather than highlighted source code:

```latex
\operatorname{Attention}(Q,K,V)=\operatorname{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V
```

Other fenced languages, including python, remain native highlighted-code blocks.

Use preview_draft before any future write action. It returns:

  • A short preview_text.
  • Formatting and fidelity warnings.
  • Draft stats.
  • An images manifest containing every exact URL to be inserted and any supplied dimensions, format, alt text, caption, and title.
  • A confirmation_token bound to the action, draft id, title, subtitle, audience, and generated draft payload.
  • A confirmation_expires_at timestamp.

The token is not authentication. It is a write-safety guard for create_draft and update_draft. Those tools recompute the draft payload and reject missing, expired, malformed, or mismatched tokens before writing to Substack. confirmation_expires_at is exclusive; tokens are invalid at that timestamp.

Title and subtitle metadata are trimmed and whitespace-collapsed before confirmation-token matching and before sending draft payloads to Substack. Draft body content is not whitespace-normalized.

create_draft sends an audience value only when the caller explicitly provides one. If omitted, the server leaves audience selection to Substack's draft defaults instead of forcing everyone, only_free, only_paid, or founding.

create_draft also accepts an optional idempotency_key to reduce accidental duplicate draft creation. Successful creates are remembered in memory for the preview-token TTL. A repeat call with the same key and same generated draft payload returns the original draft result without another Substack create call; reusing the same key for different draft input is rejected. This cache is per running process only and is not durable across restarts, redeployments, or multiple Cloud Run instances.

update_draft updates only fields provided by the caller. It can update the body plus metadata, metadata only, or one native image through image_patch. Ordinary body input is a full replacement and must contain everything the caller wants to preserve; the targeted image path instead patches the fetched native JSON and sends only draft_body. Update payloads omit create-only fields such as type. If no audience is provided, it preserves the current audience instead of defaulting to everyone. It refuses to update drafts that Substack does not report as plain unpublished drafts. A fetched draft must include a positive draft/unpublished marker such as status: "draft", status: "unpublished", draft: true, is_draft: true, or is_published: false; explicit non-draft status, draft: false, is_draft: false, is_published, published_at, or post_date markers still block the update.

Use npm run inspect:draft -- <draft_id> only when you have local Substack credentials configured and need to inspect a live draft payload. Configuration loads .env.local first, then .data/substack-auth.json, then .env, without replacing values already present in the process. The script prints a JSON payload with the parsed draft_body when possible. It omits the full raw Substack response unless --include-raw is passed. Raw response inclusion is allowed only for stdout or an explicit --output .data/<file>.json private inspection file; --fixture captures always stay compact, non-raw, and body-only by writing the parsed draft_body without draft title, subtitle, audience, or raw metadata. To write one of the expected adapter fixtures directly, use npm run inspect:draft -- <draft_id> --fixture latex-block, replacing latex-block with inline-marks, image, or code-block as needed. Pass --fixture-dir <project-local-dir> when the target fixture set is outside fixtures/substack/.

Use npm run create:fixture -- --dry-run to validate the rich Markdown fixture without network calls. To create one of the exact fixture drafts expected by the adapter tests, pass --kind inline-marks, --kind image, --kind code-block, or --kind latex-block; pass --kind all to dry-run or create all four required fixture drafts in one command. When Substack credentials are configured, run the same command without --dry-run to upload a tiny test image when a fixture contains one, create [MCP FIXTURE] drafts, and print each matching inspect:draft --fixture <kind> command for fixture capture. If --fixture-dir <project-local-dir> is set, the printed inspect commands include the same fixture directory. To create and capture all four required fixture files in one live run, use npm run create:fixture -- --kind all --capture; this writes inspected body-only, non-raw fixture JSON under fixtures/substack/. Pass --fixture-dir <project-local-dir> to write capture output somewhere else, then check that same directory with npm run fixtures:status -- --fixture-dir <project-local-dir>. The script output includes per-fixture provenance_review strings, and --kind all also emits gate_7_fixture_provenance_review; copy that safe summary into the gate 7 evidence artifact after manual editor review. Dry-run provenance is intentionally rejected by v1:record for gate 7 because it does not prove live fixture capture.

Use npm run fixtures:status to see which required live fixture files are still missing, invalid, or incompatible with the current adapter output. The status check requires parseable Substack draft-body JSON, rejects reusable fixture files that include top-level draft metadata or raw response fields, requires the expected feature-specific shape for each fixture, and requires exact round-trip compatibility through toSubstackProseMirror; incompatible fixtures report a safe first structural difference plus node/mark summaries without printing draft text or URLs. The command also prints the exact per-fixture capture or recapture command to run next. Once all four fixtures are expected to be present, use npm run fixtures:status -- --require-all and npm test -- tests/content/substackFixtureCompatibility.test.ts as local release gates before calling V1 complete. For a non-default fixture directory, run the test with SUBSTACK_FIXTURE_DIR=<project-local-dir>; the compatibility test rejects values that resolve outside the project.

When running the guarded live Substack test for acceptance evidence, set V1_LIVE_SUBSTACK_EVIDENCE_ARTIFACTS to write sanitized project-local Markdown artifacts with draft metadata, automated live-flow proof, and manual review checklists for gates 7, 8, and 9 in one live run. npm run v1:preflight checks configured artifact env paths before the live test runs and reports unsafe paths without printing the raw path values. Generated plural artifacts include gate-specific v1:record commands that point gate 7, 8, and 9 at their corresponding artifact paths; the gate 7 command also includes the fixture readiness and fixture compatibility test gates that must pass before recording rich-draft formatting evidence, and the artifact includes structured gate 7 detail fields for fixture readiness, compatibility, provenance, formatting review, unpublished status, and cleanup. If live fixture capture is staged outside fixtures/substack/, set SUBSTACK_FIXTURE_DIR=<project-local-dir> in the same shell so the generated gate 7 record command and detail fields preserve that directory. The older singular V1_LIVE_SUBSTACK_EVIDENCE_ARTIFACT is still supported for one artifact path.

V1_LIVE_SUBSTACK_EVIDENCE_ARTIFACTS=.data/v1/gate-07-rich-draft-live-fixtures.md,.data/v1/gate-08-update-draft-live.md,.data/v1/gate-09-read-drafts-live.md RUN_LIVE_SUBSTACK_TESTS=1 npm run test:live

Use npm run v1:preflight to check local prerequisites for the remaining live/manual gates without printing secret values. It reports whether the current Node runtime and package engine contract meet the Node 20+ floor, whether the current runtime can also run the @modelcontextprotocol/inspector@latest CLI used by npm run smoke:inspector, whether required V1 helper scripts are present, whether required env names are present, whether live fixtures are ready, whether SUBSTACK_FIXTURE_DIR is project-local and matches the preflight fixture directory when set, whether optional V1_LIVE_SUBSTACK_EVIDENCE_ARTIFACT and V1_LIVE_SUBSTACK_EVIDENCE_ARTIFACTS paths stay project-local when set, whether MCP_REMOTE_URL is safe when set for remote smoke commands, whether MCP_PATH_SECRET is a valid single URL-safe path segment when set, whether OAuth remote metadata is configured when AUTH_MODE=oauth, whether BILLING_ACCOUNT_ID is set for the Cloud Run budget-alert follow-up, and whether local tools such as npx, ngrok, gcloud, and docker are available. When fixtures are not ready, run npm run fixtures:status first; it prints the per-fixture capture or recapture command, and preflight also prints the matching fixture compatibility test command. If live fixture capture is staged outside fixtures/substack/, pass the same --fixture-dir <project-local-dir> to v1:preflight, v1:status, and v1:runbook so gate 7 readiness and suggested commands reference that directory; if SUBSTACK_FIXTURE_DIR is set for the compatibility test, use the same path there. Add --require-live when you want the command to fail unless required live prerequisites are ready.

Use npm run v1:status to see all 17 V1 acceptance criteria in one report, including live fixture present, valid, and adapter-compatible counts. The command does not run tests, deploy Cloud Run, call Substack, or prove manual client acceptance. After you complete manual/live gates, keep a local ignored evidence file such as .data/v1-acceptance-evidence.json using examples/v1-acceptance-evidence.example.json as a template for all recordable manual/live gates; v1:status automatically reads that default file when it exists, and --evidence-file can still point at a custom file. v1:record only accepts manual/live gates 7, 8, 9, 11, 12, 13, 14, 15, and 16; repo-local gates are proved by the listed commands and validation artifacts. If live fixtures are staged outside fixtures/substack/, pass the same --fixture-dir <project-local-dir> to v1:record so its printed status and release-gate commands preserve that context. Notes-only records are allowed for drafting, but v1:status only counts a recorded gate complete when the record includes an existing scanned artifact:

npm run v1:preflight
npm run v1:preflight -- --fixture-dir fixtures/live
npm run v1:preflight -- --require-live
npm run v1:runbook -- --gate 7
npm run v1:runbook -- --gate 7 --fixture-dir fixtures/live
npm run v1:runbook -- --gate 12 --mcp-path-secret mcp-path-secret
npm run v1:runbook -- --output .data/v1/manual-acceptance-runbook.md
npm run v1:runbook -- --write-artifacts
npm run v1:runbook -- --gate 7 --write-artifacts
npm run v1:record -- --gate 11 --evidence "ChatGPT connector listed tools and completed the manual ngrok flow" --command "npm run smoke:ngrok-noauth -- --use-local-credentials --hold-open-seconds 900 --evidence-artifact .data/v1/gate-11-chatgpt-ngrok.md; ChatGPT connector manual workflow during bounded tunnel window" --artifact .data/v1/gate-11-chatgpt-ngrok.md
npm run v1:record -- --gate 7 --fixture-dir fixtures/live --evidence "Live fixture-backed draft formatting passed" --command "npm run fixtures:status -- --fixture-dir fixtures/live --require-all; SUBSTACK_FIXTURE_DIR=fixtures/live npm test -- tests/content/substackFixtureCompatibility.test.ts; V1_LIVE_SUBSTACK_EVIDENCE_ARTIFACTS=.data/v1/gate-07-rich-draft-live-fixtures.md,.data/v1/gate-08-update-draft-live.md,.data/v1/gate-09-read-drafts-live.md RUN_LIVE_SUBSTACK_TESTS=1 npm run test:live" --artifact .data/v1/gate-07-rich-draft-live-fixtures.md
npm run v1:status -- --evidence-file .data/v1-acceptance-evidence.json
npm run v1:status -- --fixture-dir fixtures/live
npm run v1:status -- --evidence-file .data/v1-acceptance-evidence.json --require-complete

v1:runbook renders the current manual/live gate checklist with suggested commands, artifact paths, and v1:record examples. Add --gate <id> one or more times to focus the runbook and artifact templates on selected manual/live gates. Add --write-artifacts to create missing Markdown evidence templates at the suggested .data/v1/ paths without overwriting existing notes.

Those templates include gate-specific evidence checklists. Gate 7 also includes explicit manual review fields for fixture readiness, fixture compatibility, fixture provenance, text formatting, image rendering, native code block rendering, native LaTeX rendering, preview behavior, unpublished status, and cleanup. The provenance field must identify how the live fixtures were captured or manually reviewed, so adapter compatibility is not treated as native editor proof by itself; v1:record rejects gate 7 artifacts unless the readiness field shows fixtures:status -- --require-all, the compatibility field shows the Substack fixture compatibility test command, the draft reference is a Substack draft URL or numeric draft ID, the title/subtitle review confirms expected values, the rich-rendering review fields affirmatively identify the reviewed feature and successful rendering without vague, failed, missing, fallback, raw, plain, or unreviewed output, the unpublished-status field explicitly confirms the created item remained unpublished without contradictory published/public/live wording, and the cleanup decision records whether the live draft was kept, deleted, or left for further review without publishing. Gate 8 includes explicit update review fields for draft reference, title transition, update command, field update result, unpublished status, post-update readback, body handling, and cleanup decision; v1:record rejects gate 8 artifacts whose draft reference is not a Substack draft URL or numeric draft ID, whose title fields do not show a changed draft title, whose update-command field does not identify the guarded live run or an explicit live update_draft MCP/client workflow, whose field-update review does not confirm a successful change, whose post-update readback does not confirm the updated draft was read back, whose draft-body handling does not confirm raw body/content stayed out of evidence, whose unpublished-status-after-update field does not explicitly confirm the updated item remained unpublished, whose status wording contradicts that with published/public/live evidence, or whose cleanup decision does not record kept/deleted/left-for-review without publishing. Gate 9 includes explicit read review fields for draft reference, list result, get result, metadata reviewed, body-inclusion decision, post-update readback, raw-content handling, and follow-up action; v1:record rejects gate 9 artifacts whose draft reference is not a Substack draft URL or numeric draft ID, whose list result does not confirm the expected draft was listed, whose get result does not confirm expected draft metadata was fetched, whose metadata review does not name id/title plus url or status, whose body-inclusion review does not confirm body/content stayed unrequested or metadata-only, whose post-update readback does not confirm the updated draft was read back, whose raw body/content handling field does not confirm raw draft body/content was omitted, redacted, metadata-only, or otherwise not recorded in evidence, or whose follow-up action does not record no-action reasoning, cleanup, or a concrete follow-up after the live read review. Gate 11 includes explicit ChatGPT connector fields for connector URL, ChatGPT surface, tool-list result, manual flow result, draft or review reference, tunnel exposure window, unexpected-traffic review, and rotation decision; v1:record rejects gate 11 artifacts whose connector URL does not identify the public HTTPS /mcp endpoint or a URL-proving reference, whose ChatGPT surface field does not name ChatGPT, whose tool-list field does not confirm exactly seven V1 draft-workflow tools through the ChatGPT connector, or whose draft/reference field does not provide a non-sensitive draft URL, numeric ID, screenshot reference, or manual review reference.

Gate 12 includes explicit Cloud Run deployment fields for project/region/service, service URL check, deploy command source, health check, remote smoke, deployed auth mode, budget guard, and path-secret review; v1:record rejects gate 12 artifacts whose deployment details do not identify the project, region, and Cloud Run service, include a deployed HTTPS service URL, cite cloud-run:plan or gcloud run deploy provenance, confirm /healthz success, confirm auth-mode matching remote MCP smoke success with auth-mode evidence, name noauth, static_bearer, or oauth, confirm a Cloud Billing budget alert command/result or explicit budget-alert follow-up decision, and state that MCP_PATH_SECRET was not used or that the private path segment was verified without recording its value. Generic "remote smoke passed" wording does not satisfy gate 12, and contradictory path-secret wording that claims non-use or shell-only handling while also saying the segment value was copied, pasted, recorded, logged, stored, or exposed does not satisfy gate 12. Gate 13 includes explicit Secret Manager fields for required references, secret names, runtime service account, access bindings, version policy, literal-env review, secret-value handling, and rotation follow-up; v1:record rejects gate 13 artifacts whose literal-env review does not confirm secrets are Secret Manager references instead of literal environment values, whose secret-value handling does not confirm raw secret values were not opened, pasted, exposed, or recorded, or whose no-action/no-rotation follow-up says credential/token/secret/cookie exposure or unresolved unexpected traffic occurred. Gate 14 includes explicit header-capable client fields for client version, public HTTPS /mcp endpoint reference, header configuration method, tool-list result, validation result, bearer-rejection proof, and token-redaction review; v1:record rejects gate 14 artifacts whose endpoint field does not identify the public HTTPS /mcp endpoint or whose header configuration method does not describe an Authorization header. Gate 15 includes explicit manual client fields for Claude Code/Cursor version, Claude Code/Cursor stdio config path or add command, stdio entrypoint path, tool-list result, validation result, and local-credentials review; v1:record rejects gate 15 artifacts whose client-tested field does not name Claude Code or Cursor or whose config field does not identify the stdio config path or add command. Gate 16 includes the same required Cloud Run log-review checklist items enforced for generated log evidence, plus explicit fields for export window, service/revision, live traffic exercised, export command, verifier command, audit event review, finding review, raw-log handling, and follow-up action; v1:record rejects gate 16 artifacts whose log window, Cloud Run target, export command, verifier command, finding review, live-traffic, audit-event review, raw-log handling, or follow-up action fields do not provide the required Cloud Run log-safety provenance.

For gate 11, v1:record also rejects weak ChatGPT/ngrok evidence unless the manual fields confirm an actual ChatGPT surface, the public HTTPS /mcp connector URL, exact seven-tool ChatGPT connector listing, ChatGPT validate/preview/create draft flow, non-sensitive draft or review reference, bounded tunnel window with start/end or duration evidence, reviewed ngrok or local traffic logs with no unresolved unexpected access, and a tunnel or credential rotation decision with a reason. Wording that says the flow was not ChatGPT, only a local or remote smoke, or still pending does not satisfy gate 11; the traffic review must identify reviewed ngrok/local/server logs, not just assert that no unexpected requests occurred; no-rotation wording does not satisfy the rotation field when the reason says a credential, token, secret, cookie, or unresolved unexpected traffic was exposed.

For gate 13, v1:record also rejects weak Secret Manager evidence unless the manual fields confirm Secret Manager references for SUBSTACK_SESSION_TOKEN and PREVIEW_TOKEN_SECRET, list the corresponding Secret Manager resource names without values, identify the deployed runtime service account, confirm Secret Manager Secret Accessor bindings for that service account, record latest/pinned version policy with rotation notes, confirm no literal secret env values, confirm raw secret values were not opened or recorded, and record no-action reasoning or a rotation/redeploy follow-up. Negated, missing, or pending Secret Accessor binding wording does not satisfy gate 13, and contradictory secret-value handling that claims omission while also saying raw secret values were copied, pasted, recorded, logged, exposed, or placed in notes does not satisfy gate 13.

For gate 14, v1:record also rejects weak static-bearer evidence unless the manual fields name a real header-capable HTTP client, identify the public HTTPS /mcp endpoint tested through that client, describe the Authorization header setup without the token value, confirm exactly seven V1 draft-workflow tools were listed through that client, confirm validate_newsletter_content succeeded, prove missing and wrong bearer credentials were rejected with 401 or unauthorized responses, and confirm no bearer token or token value was included, recorded, printed, pasted, exposed, or logged. Negated, token-free, smoke-only, pending Authorization-header wording, or contradictory token-redaction wording that later says a token value was copied, pasted, recorded, logged, stored, or exposed does not satisfy gate 14.

For gate 15, v1:record also rejects weak Claude Code/Cursor stdio evidence unless the manual fields name a real Claude Code or Cursor client, identify the Claude Code/Cursor stdio config path or add command, identify an absolute dist/stdio.js entrypoint path, confirm exactly seven V1 draft-workflow tools were listed through that client, confirm validate_newsletter_content succeeded, and confirm Substack credentials came from local environment variables without being recorded, committed, pasted into client config, or exposed remotely. Negated, MCP-Inspector-only, smoke-only, pending Claude Code/Cursor wording, or contradictory credential-locality wording that later says credential values were pasted, copied, committed, logged, exposed, or placed in client configuration does not satisfy gate 15.

For gate 11, gate 14, gate 15, and remote OAuth launch review, weak tool-list wording that only says seven tools were listed without identifying the V1 draft-workflow surface does not satisfy the tool-list field. Contradictory tool-list wording that claims exactly seven V1 draft-workflow tools while also saying another tool count was listed, visible, returned, or shown also does not satisfy the tool-list field.

For gate 16, v1:record also rejects weak Cloud Run log evidence unless the manual fields identify the live log export window, Cloud Run service/revision or service URL, live Cloud Run acceptance traffic, gcloud logging read command with a Cloud Run resource filter, cloud-run:logs:verify command with --logs-json and --require-audit-events, metadata-only audit review, no-findings or sanitized finding summary, raw-log exclusion from evidence, and a rotation/redeploy/deletion or no-action decision with a reason. Local-only, dry-run, no-deployed-request, or other negated live-traffic wording does not satisfy gate 16; negated metadata-only audit wording or audit events with raw request/body/secret fields also fail; category-specific "no secret findings" wording is too narrow unless recorded as a sanitized finding summary; contradictory raw-log wording such as excluding logs but copying them into notes also fails; no-action/no-rotation decisions do not satisfy the follow-up field when the reason describes unresolved exposure or findings.

The evidence file is an operator-supplied record for review. Gate 7 still requires live Substack fixture readiness from npm run fixtures:status -- --require-all plus the fixture compatibility test; the runbook now starts that gate with npm run fixtures:status so individual missing or stale fixtures can be captured before running the all-fixtures release gate and compatibility test. Gate 12 commands define SERVICE_URL from the deployed Cloud Run service, reuse it for /healthz and remote MCP smoke checks, and guard auth-mode smoke commands on the required bearer-token env values. If the Cloud Run deployment used a private MCP path segment, pass --mcp-path-secret <secret-name> to v1:runbook; gate 12/13 commands then include the matching cloud-run:plan and cloud-run:verify flags plus shell-only MCP_PATH_SECRET guards for remote smoke URLs. With --fixture-dir <project-local-dir>, v1:runbook also writes gate 7 suggested commands, evidence templates, v1:record examples, and final v1:status --require-complete instructions that include the matching fixture directory. Recorded evidence alone does not satisfy missing, invalid, or adapter-incompatible fixture captures. Gate 16 requires an exported live Cloud Run log pass from npm run cloud-run:logs:verify -- --logs-json .data/cloud-run-logs.json --require-audit-events --evidence-artifact .data/v1/gate-16-cloud-run-logs.md. When using --artifact, pass a project-local file path that already exists. v1:record rejects future verified_at timestamps and scans evidence text, command text, and text artifacts for secret-like values, draft-body fields, unchecked checklist items, unfilled placeholders, missing gate-specific detail sections, and missing or empty gate-specific detail fields before writing the evidence JSON. Generated live Substack artifacts for gates 7/8/9 must include current automated proof from test:live for the relevant create, update, or read flow, gate 7/8 artifacts must explicitly confirm the created or updated item remained unpublished, and gate 9 artifacts must explicitly confirm raw draft body/content was not recorded in evidence. Generated Cloud Run verifier artifacts for gates 12/13 must show Complete: yes, generated auth-mode proof, passing runtime-env check IDs emitted by the current cloud-run:plan follow-up, and completed deployment or Secret Manager checklist items for the recorded gate. Generated gate 11 ChatGPT/ngrok artifacts must include current noauth remote-smoke proof for /healthz, the seven-tool surface, validate_newsletter_content, and preview_draft from smoke:remote-noauth or smoke:ngrok-noauth, and the manual ChatGPT surface field must name ChatGPT. Generated gate 14 static-bearer remote artifacts must include the current missing-token and wrong-token 401 rejection proof from smoke:remote or smoke:ngrok-static-bearer, identify the public HTTPS /mcp endpoint, and the manual header configuration field must describe an Authorization header. Generated gate 15 stdio client artifacts must include current local stdio smoke proof for the seven-tool surface, validate_newsletter_content, and preview_draft from smoke:stdio, plus the Claude Code/Cursor stdio config path or add command. Generated Cloud Run log artifacts for gate 16 must show Complete: yes, at least one audit event, all verifier checks passing, completed log-review checklist items, and manual details proving the log export window, Cloud Run service/revision, export command, verifier command, live traffic, metadata-only audit events, finding review, raw-log exclusion, and follow-up decision. Generated remote OAuth artifacts are not numbered v1:record gates, but when scanned for durable/public ChatGPT launch review they must include current OAuth smoke proof, authorization-server discovery proof with authorization-code and PKCE S256 support, a completed launch checklist, and manual OAuth launch-review fields confirming the intended authorization server, ChatGPT connector/client, browser authorization-code + PKCE login, connector registration, protected-resource metadata/CORS/challenge review, issuer/audience-or-resource/expiry/scope token validation, exact seven-tool listing, validation and preview calls, token error-path rejection, token/code/credential redaction, and a durable ChatGPT OAuth launch decision; contradictory OAuth redaction wording that claims token/code/credential omission while also saying a value was copied, pasted, recorded, logged, stored, or exposed does not satisfy the launch review. Binary artifacts are allowed for screenshots, but review them manually because the scanner cannot inspect image contents. v1:status rejects hand-edited evidence entries for repo-local gates, reruns the same timestamp and text safety checks for hand-edited or older evidence files, and only counts recorded evidence with an artifact while that artifact remains available as a file and passes the current scan.

Generated gate 11 artifacts must pair the current noauth smoke proof with completed ChatGPT manual fields for validate, preview, and draft creation; bounded tunnel exposure; traffic-log review; and tunnel shutdown, credential rotation, revocation, or an intentional no-rotation reason.

Generated gate 13 artifacts must pair the current Cloud Run verifier proof with completed manual fields for Secret Manager env references, secret resource names, runtime service account, Secret Accessor IAM bindings, version policy, secret-value handling, and rotation/redeploy follow-up.

Generated gate 14 artifacts must pair the current static-bearer smoke proof with completed manual fields for the real header-capable HTTP client, public HTTPS /mcp endpoint, exact seven-tool listing, validation success, missing/wrong bearer rejection, and bearer-token redaction review.

Generated gate 15 artifacts must pair the current local stdio smoke proof with completed manual fields for the real Claude Code/Cursor client, stdio config path or add command, absolute built stdio entrypoint path, exact seven-tool listing, validation success, and local environment credential handling.

Quickstart

Verify both local MCP transports without credentials or Substack network calls:

npm ci
npm run test:mcp-local

This builds the package, starts the built HTTP and stdio entrypoints in turn, verifies the exact seven-tool draft-workflow surface, and calls validate_newsletter_content plus preview_draft with the rich Markdown fixture. It does not call Substack or run write tools.

The local credentialed sequence is:

npm run mcp:preflight
npm run mcp:preflight -- --require-live
RUN_LIVE_SUBSTACK_TESTS=1 npm run test:live

npm run mcp:preflight reports local Substack credential readiness without requiring Cloud Run, ngrok, Cloud Billing, or release-ready fixture checks. Add -- --require-live for the fail-fast credential gate before opting into the guarded account test. The preflight command is verified with missing and valid-shaped local configuration; the real account test still requires your credentials and explicit opt-in. The live harness connects an MCP SDK Client to the registered server through the SDK in-memory transport, verifies the exact seven-tool surface (validate_newsletter_content, preview_draft, create_draft, update_draft, list_drafts, get_draft, and upload_image), and invokes upload, preview, create, list, get, update, and post-update get through client.callTool. That exercises MCP input schemas, server registration, and result wrappers in addition to the underlying Substack behavior. The harness structure and guarded skip are covered by deterministic tests; the real-account path has not been run without local credentials. Cloud Run deployment remains deferred.

To exercise MCP Inspector CLI as well, use Node 22.7.5 or newer and run:

npm run smoke:inspector

For an interactive local HTTP server, create the development environment file and start the server:

cp .env.example .env
npm run dev:http

The copied defaults are enough to connect a local client and test validate_newsletter_content and preview_draft. Configure the real Substack credential variables only when you are ready to test list_drafts, get_draft, upload_image, create_draft, or update_draft against your own account.

The local HTTP MCP endpoint defaults to:

http://localhost:8787/mcp

Health check:

curl http://localhost:8787/healthz

The individual local HTTP checks are also available:

npm run smoke:docker-http
npm run smoke:http-local
npm run smoke:inspector
npm run smoke:http-static-bearer
npm run smoke:http-oauth

To run the full repo-local V1 acceptance matrix in one command, including the fixture draft dry-run, status reports, runbook render, Docker smoke, local HTTP smoke, MCP Inspector CLI smoke, local static-bearer/OAuth HTTP smokes, and stdio smoke:

npm run validate:v1-local

This command intentionally does not use fixtures:status -- --require-all, v1:status -- --require-complete, npm run test:live, ngrok, ChatGPT, Cloud Run, or remote client acceptance. Those remain manual/live V1 gates.

npm run smoke:docker-http builds the Docker image, starts the HTTP MCP server in a local container, waits for /healthz, lists tools over Streamable HTTP, verifies the exact V1 draft-workflow tool surface, and calls validate_newsletter_content plus preview_draft with the rich fixture. It uses safe container env defaults and does not call Substack or run write tools.

npm run smoke:http-local launches node dist/http.js on a temporary localhost port, waits for /healthz, lists tools over Streamable HTTP, verifies the exact V1 draft-workflow tool surface, and calls validate_newsletter_content plus preview_draft with fixtures/markdown/full-rich-draft.md. It uses safe local smoke-test env defaults and does not call Substack or run write tools.

npm run smoke:inspector runs the same local HTTP smoke with MCP Inspector CLI tools/list enabled against the same endpoint. The server runtime floor remains Node 20+, but the current @modelcontextprotocol/inspector@latest package requires Node 22.7.5 or newer for this helper. Add --evidence-artifact .data/v1/gate-03-mcp-inspector.md to write a sanitized Markdown artifact for gate 3.

npm run smoke:http-static-bearer launches the same built HTTP entrypoint in AUTH_MODE=static_bearer, verifies missing and wrong bearer tokens return 401 with a bearer challenge, then lists tools and calls validate_newsletter_content plus preview_draft with the correct local smoke token. It does not call Substack or run write tools.

npm run smoke:http-oauth launches the built HTTP entrypoint in AUTH_MODE=oauth, starts a local HTTPS authorization server with discovery metadata plus a JWKS endpoint backed by a throwaway signing key, verifies protected-resource metadata, authorization-server discovery, and missing bearer-token challenges, then lists tools and calls validate_newsletter_content plus preview_draft with a signed JWT. It disables TLS verification only around the generated self-signed local authorization server/JWKS calls used by the smoke test; it does not call Substack or run write tools.

MCP Inspector

Run the local HTTP server:

npm run dev:http

In another shell:

npx @modelcontextprotocol/inspector@latest --server-url http://localhost:8787/mcp --transport http

The inspector should list list_drafts, get_draft, validate_newsletter_content, preview_draft, create_draft, update_draft, and upload_image.

For non-interactive V1 gate evidence, run the local smoke artifact helper with Inspector CLI enabled:

npm run build
npm run smoke:inspector -- --evidence-artifact .data/v1/gate-03-mcp-inspector.md

Substack Credentials

Use credentials only for your own Substack account/publication. Run:

npm run auth:setup

This opens a temporary page bound to 127.0.0.1. On macOS, enter the publication URL and select Use current Chrome session. After that explicit action, setup copies the cookie store from Chrome's most recently used profile into a temporary owner-only directory, launches headless Chrome with network access disabled, waits for the encrypted cookie store to load, removes every cookie outside substack.com and the requested publication domain, and re-enables network access. It then reads SUBSTACK_USER_ID from the authenticated publication dashboard, validates administrative access through Substack's publication API, removes the temporary profile, generates PREVIEW_TOKEN_SECRET, and saves the four runtime values in .data/substack-auth.json. It does not copy Chrome history or password databases. The auth file is ignored by git and written with owner-only permissions. It is loaded after .env.local and before .env; explicit process or .env.local values retain precedence.

The current-session flow requires Google Chrome on macOS and may trigger a macOS Keychain confirmation so the temporary Chrome process can decrypt its local cookie-store copy. It never modifies the normal Chrome profile. If the most recently used Chrome profile is not the one signed in to Substack, expand Sign in with a temporary Chrome window. Manual cookie entry remains the final fallback and does not require browser developer tools beyond locating the cookie.

The four runtime values are:

  • SUBSTACK_PUBLICATION_URL: public https:// publication origin, preferably the canonical https://<publication>.substack.com origin. Copied Substack post/editor paths, queries, and fragments are normalized to the origin.
  • SUBSTACK_SESSION_TOKEN: the relevant browser session cookie value for your own account. The saved auth file and environment variable contain only the value.
  • SUBSTACK_USER_ID: positive numeric Substack user id derived by the setup flow.
  • PREVIEW_TOKEN_SECRET: locally generated HMAC key for write-confirmation tokens.

The manual fallback accepts a bare cookie value, connect.sid=..., substack.sid=..., or a copied Cookie: header and extracts only the session value locally. Because it does not load an authenticated dashboard, it also requires the numeric Substack user ID:

  1. Sign in to Substack in a browser and open the publication dashboard, preferably at the canonical https://<publication>.substack.com/publish/home origin.
  2. Open browser developer tools, then Application or Storage, then Cookies for that publication origin. Copy the connect.sid or substack.sid row or value.
  3. Expand the manual option on the loopback setup page, paste the cookie, and enter the numeric user ID. Do not put the cookie in a command-line argument, MCP tool call, chat, log, or evidence artifact.

With either browser option, you do not need to inspect the Network tab for a user ID; setup reads it from the authenticated dashboard.

The Substack setup flow is separate from AUTH_MODE=oauth. MCP OAuth authenticates an MCP client to this server; it does not authenticate this server to Substack. The setup flow remains single-account, process-wide configuration and is intentionally not exposed as an MCP tool.

For manual or automated environments, the same values can still be supplied through process environment variables or .env.local. Keep these out of git. Cloud Run configuration remains deferred for the current local-testing target.

After setting local credentials, run npm run mcp:preflight; use npm run mcp:preflight -- --require-live when missing or invalid live prerequisites must fail fast. This verified local check deliberately excludes Cloud Run, ngrok, Cloud Billing, and release-ready fixture requirements. The guarded account test is RUN_LIVE_SUBSTACK_TESTS=1 npm run test:live; it uses an MCP SDK Client over in-memory transport to exercise the registered server. It must only create and update an unpublished draft, and the printed draft ID/URL still requires manual review and cleanup in Substack. Cloud Run remains deferred for the current local-testing target.

Local MCP Clients

Build first:

npm run build
npm run smoke:stdio
npm run smoke:stdio -- --evidence-artifact .data/v1/gate-15-stdio-client.md

Generic stdio config is in examples/mcp.local.stdio.json. Replace the repository path in its shell command. The command changes into this checkout so the server can read the ignored .data/substack-auth.json file without copying Substack secrets into the MCP client configuration.

Step-by-step Claude Desktop and Claude Code instructions are in the client setup guide.

npm run smoke:stdio launches node dist/stdio.js, lists tools over MCP stdio, verifies the exact V1 draft-workflow tool surface, and calls validate_newsletter_content plus preview_draft with the rich fixture. It uses safe local smoke-test env defaults and does not call Substack or run write tools. Add --evidence-artifact .data/v1/gate-15-stdio-client.md to write a sanitized Markdown artifact with current local stdio smoke proof, the remaining Claude Code/Cursor manual checklist, and structured fields for the tested client version, Claude Code/Cursor stdio config path or add command, stdio entrypoint path, tool-list result, validation result, and local-credentials review.

Remote HTTP config with static bearer auth is in examples/mcp.remote.http.json.

Claude Code setup examples are in examples/claude-code.md. The local stdio example changes into this checkout and reads the ignored local auth file; it does not persist Substack credentials in Claude Code configuration. The remote HTTP examples keep Substack credentials on the server and use either a client bearer header for AUTH_MODE=static_bearer or an OAuth login flow for AUTH_MODE=oauth.

Cursor examples are split by transport:

  • examples/cursor.local.stdio.json for a local stdio .cursor/mcp.json.
  • examples/cursor.remote.http.json for a remote HTTP .cursor/mcp.json.

The real .cursor/mcp.json file is ignored by git because it can contain machine-local paths or environment wiring. The built stdio entrypoint is locally smoke-tested; real Claude Code/Cursor acceptance is still a manual V1 gate.

To smoke-test a remote HTTP endpoint that uses AUTH_MODE=static_bearer, keep the bearer token in the environment, prove missing/wrong bearer tokens are rejected, list tools, and call the read-only validation and preview tools:

: "${MCP_BEARER_TOKEN:?Set MCP_BEARER_TOKEN to the remote static bearer token first}" && \
  MCP_BEARER_TOKEN="$MCP_BEARER_TOKEN" npm run smoke:remote -- --url https://<host>/mcp
: "${MCP_BEARER_TOKEN:?Set MCP_BEARER_TOKEN to the remote static bearer token first}" && \
  MCP_BEARER_TOKEN="$MCP_BEARER_TOKEN" npm run smoke:remote -- --url https://<host>/mcp --evidence-artifact .data/v1/gate-14-static-bearer-remote.md

Remote smoke URLs must use HTTPS, must use /mcp or /mcp/<secret>, and must not include embedded usernames/passwords. Keep authentication in the documented environment variables, not in the URL.

The smoke test checks /healthz on the same origin, verifies that missing and wrong bearer-token requests return 401 with a Bearer challenge, verifies that the server exposes exactly the V1 draft-workflow tools, and can call validate_newsletter_content plus preview_draft with the rich fixture. It does not call Substack and does not run write tools. Add --evidence-artifact .data/v1/gate-14-static-bearer-remote.md to write a sanitized Markdown artifact with the remote smoke result, the remaining header-capable client checklist, and structured fields for the tested client version, public HTTPS /mcp endpoint reference, header configuration method, tool-list result, validation result, bearer-rejection proof, and token-redaction review.

For short-lived static-bearer tunnel testing, build and run the combined local HTTP/ngrok/static-bearer smoke:

npm run build
npm run smoke:ngrok-static-bearer
npm run smoke:ngrok-static-bearer -- --evidence-artifact .data/v1/gate-14-static-bearer-remote.md
: "${MCP_BEARER_TOKEN:?Set MCP_BEARER_TOKEN to a local test token first}"
npm run smoke:ngrok-static-bearer -- --bearer-token-env MCP_BEARER_TOKEN --hold-open-seconds 900 --evidence-artifact .data/v1/gate-14-static-bearer-remote.md

The command uses a fake local bearer token by default, launches node dist/http.js in AUTH_MODE=static_bearer, starts ngrok http 8787, discovers the HTTPS forwarding URL from the local ngrok API, verifies remote missing and wrong bearer tokens are rejected, checks remote /healthz, lists the exact V1 draft-workflow tools, and calls the read-only validation and preview tools with the rich fixture. It does not call Substack or run write tools. To use a real header-capable client, keep a test token in the environment, pass its variable name through --bearer-token-env, and add --hold-open-seconds <0-3600>. The command prints the live endpoint before waiting, never prints the token value, and shuts the server and tunnel down when the bounded window ends or you press Ctrl-C.

To smoke-test a remote HTTP endpoint that uses AUTH_MODE=oauth, first complete the OAuth login or token acquisition flow outside this script, keep the access token value in an environment variable, then verify metadata, tools, and read-only validation. Store only the token value, not an Authorization: header or Bearer ... prefix:

: "${MCP_OAUTH_BEARER_TOKEN:?Set MCP_OAUTH_BEARER_TOKEN to a real access token first}" && \
  MCP_OAUTH_BEARER_TOKEN="$MCP_OAUTH_BEARER_TOKEN" npm run smoke:remote-oauth -- --url https://<host>/mcp --evidence-artifact .data/v1/remote-oauth.md

The OAuth remote smoke checks /healthz on the same origin, fetches /.well-known/oauth-protected-resource from the MCP origin, checks metadata CORS/preflight headers, checks the resource, required V1 scopes, and HTTPS authorization-server URLs without embedded usernames/passwords, fetches OAuth authorization-server metadata or OIDC discovery from the advertised issuer, verifies authorization-code, code-response, JWKS, and PKCE S256 support, sends the access token as a bearer token, lists the exact V1 draft-workflow tools, and calls validate_newsletter_content plus preview_draft with the rich fixture. It does not call Substack and does not run write tools. Add --evidence-artifact .data/v1/remote-oauth.md to write a sanitized Markdown artifact with the remote smoke result, the remaining real-login/ChatGPT OAuth checklist, and structured fields for the authorization server, OAuth client, browser authorization-code login, connector registration, protected-resource metadata, token validation, tool-list, validation, preview, error-path checks, token-redaction review, and launch decision. This artifact supports durable/public ChatGPT launch review; it is not a numbered v1:record gate artifact. If you treat it as launch evidence, the scanner rejects stale or incomplete artifacts that lack current OAuth smoke proof, authorization-server discovery proof, completed launch checklist items, or filled manual OAuth review fields.

For short-lived public-origin OAuth smoke testing without a real authorization server, build and run the combined local HTTP/ngrok/OAuth smoke:

npm run build
npm run smoke:ngrok-oauth
npm run smoke:ngrok-oauth -- --evidence-artifact .data/v1/remote-oauth.md

The command starts ngrok, serves local HTTPS authorization-server discovery and JWKS metadata with a throwaway key, launches node dist/http.js in AUTH_MODE=oauth with the ngrok origin as MCP_PUBLIC_BASE_URL, signs a JWT for that public resource, checks remote /healthz, verifies OAuth protected-resource metadata, authorization-server discovery, and metadata CORS/preflight behavior from the public origin, lists the exact V1 draft-workflow tools, and calls the read-only validation and preview tools with the rich fixture. It does not call Substack, run write tools, or complete a browser authorization-code flow; real ChatGPT OAuth acceptance still requires an intended authorization server and connector login. When --evidence-artifact .data/v1/remote-oauth.md is supplied, fill the manual launch-review section after that real login flow rather than treating the generated JWT smoke as launch proof; the launch checklist must be fully checked before the artifact can pass the evidence scanner.

Remote MCP Clients with ngrok

ChatGPT and Claude remote connectors need a public HTTPS endpoint. An ngrok account includes an automatically assigned Dev Domain that stays the same across tunnel restarts, so the connector URL does not need to change every time.

  1. Create an account at ngrok and install the ngrok agent.

  2. Copy the authtoken from the ngrok dashboard and add it once:

    ngrok config add-authtoken YOUR_NGROK_AUTHTOKEN
    
  3. Find the assigned *.ngrok-free.dev hostname in Domains in the ngrok dashboard.

  4. Start the built MCP server in one terminal:

    npm run build
    npm run mcp:preflight -- --require-live
    AUTH_MODE=noauth HOST=127.0.0.1 PORT=8787 npm run start:http
    
  5. Start the tunnel with that exact hostname in another terminal:

    ngrok http --url YOUR_ASSIGNED_DOMAIN.ngrok-free.dev 8787
    
  6. Verify the public endpoint before adding it to a client:

    npm run smoke:remote-noauth -- \
      --url https://YOUR_ASSIGNED_DOMAIN.ngrok-free.dev/mcp
    

Register https://YOUR_ASSIGNED_DOMAIN.ngrok-free.dev/mcp in the remote MCP client. The hostname is stable, but the endpoint is online only while both the local server and ngrok process are running.

ChatGPT

Enable developer mode, create a custom plugin in Settings > Plugins, and use the stable HTTPS /mcp URL with no authentication for this short-lived local flow. Availability and workspace controls vary by plan. See the complete ChatGPT tutorial and OpenAI's current setup guide.

Claude

Add the same HTTPS /mcp URL as a custom connector, then enable it for the conversation. Organization-managed plans may require an owner to add the connector first. Claude Desktop and Claude Code can also run the local stdio transport without ngrok. See the Claude tutorials and Anthropic's remote connector guide.

AUTH_MODE=noauth is for short-lived personal testing only. Anyone who discovers the live endpoint can call it while the tunnel is running. Keep the URL private, stop both processes after testing, review ngrok traffic, and rotate SUBSTACK_SESSION_TOKEN if the URL was shared or unexpected traffic appears. Use AUTH_MODE=oauth for a durable public deployment, or AUTH_MODE=static_bearer for private clients that can set an Authorization header.

The automated bounded-window alternative remains available:

npm run smoke:ngrok-noauth -- --use-local-credentials --hold-open-seconds 900

That helper uses a temporary ngrok URL unless you supply ngrok domain arguments. The full guide covers stable-domain startup, client refresh behavior, local Claude configuration, and troubleshooting.

Cloud Run

The included Dockerfile runs the HTTP server with node dist/http.js and listens on Cloud Run's PORT.

Before deploying, Docker users can verify the container entrypoint locally:

npm run smoke:docker-http

The smoke builds substack-mcp:smoke, runs it with safe fake/non-secret configuration on a temporary localhost port, checks /healthz, lists tools at /mcp, calls validate_newsletter_content plus preview_draft with the rich fixture, and stops the container. It does not read .env files, call Substack, or run write tools.

Generate a no-secret deployment command plan:

npm run cloud-run:plan -- \
  --project-id your-gcp-project-id \
  --publication-url https://yourpublication.substack.com \
  --user-id 123456 \
  --auth-mode static_bearer

To deploy a private MCP path segment at /mcp/<secret>, add --mcp-path-secret mcp-path-secret. The generated plan creates the MCP_PATH_SECRET value from the shell, injects it through Secret Manager, passes the same secret name to cloud-run:verify, and runs remote smoke commands against "$SERVICE_URL/mcp/$MCP_PATH_SECRET" without printing the path segment.

For OAuth deployments, include the public Cloud Run/custom-domain origin and OAuth issuer/JWKS URLs:

npm run cloud-run:plan -- \
  --project-id your-gcp-project-id \
  --publication-url https://yourpublication.substack.com \
  --user-id 123456 \
  --auth-mode oauth \
  --public-base-url https://your-mcp.example.com \
  --oauth-authorization-server-url https://auth.example.com \
  --oauth-jwks-url https://auth.example.com/.well-known/jwks.json \
  --oauth-jwt-algorithms RS256,ES256

The planner does not run gcloud, create resources, deploy, or read secret values. It prints commands for enabling APIs, creating the service account, creating Secret Manager placeholders, optional MCP_PATH_SECRET Secret Manager wiring, granting secret access, deploying from source, exporting and verifying the deployed service JSON, writing gate 12/13 evidence artifacts, checking the deployed /healthz endpoint, creating a project-scoped $5/month Cloud Billing budget alert with 50%, 90%, and 100% thresholds, running the correct remote smoke for the selected auth mode, and exporting/verifying Cloud Run logs for gate 16 after live acceptance traffic.

After deployment, export the service description and verify the deployed configuration without exposing secret values:

SERVICE_URL=$(gcloud run services describe substack-draft-mcp \
  --region us-central1 \
  --format='value(status.url)')

mkdir -p .data && gcloud run services describe substack-draft-mcp \
  --region us-central1 \
  --format=json > .data/cloud-run-service.json

npm run cloud-run:verify -- \
  --service-json .data/cloud-run-service.json \
  --auth-mode static_bearer \
  --service-account-email [email protected] \
  --evidence-artifact .data/v1/gate-12-cloud-run-deployment.md

curl --fail --show-error "$SERVICE_URL/healthz"

: "${MCP_BEARER_TOKEN:?Set MCP_BEARER_TOKEN to the deployed static bearer token first}" && \
  MCP_BEARER_TOKEN="$MCP_BEARER_TOKEN" npm run smoke:remote -- \
  --url "$SERVICE_URL/mcp" \
  --evidence-artifact .data/v1/gate-14-static-bearer-remote.md

If the service was deployed with --mcp-path-secret, pass the same secret name to verification and keep the path segment in the shell for remote smoke:

npm run cloud-run:verify -- \
  --service-json .data/cloud-run-service.json \
  --auth-mode static_bearer \
  --service-account-email [email protected] \
  --mcp-path-secret mcp-path-secret

: "${MCP_PATH_SECRET:?Set MCP_PATH_SECRET to the deployed MCP path segment first}" && \
  : "${MCP_BEARER_TOKEN:?Set MCP_BEARER_TOKEN to the deployed static bearer token first}" && \
  MCP_BEARER_TOKEN="$MCP_BEARER_TOKEN" npm run smoke:remote -- \
  --url "$SERVICE_URL/mcp/$MCP_PATH_SECRET" \
  --evidence-artifact .data/v1/gate-14-static-bearer-remote.md

The verifier does not run gcloud, call Cloud Run, read Secret Manager values, or prove live reachability. It checks the exported JSON for the HTTPS service URL, min-instances=0, max-instances=1, service account, production HTTP runtime env, exact non-secret runtime values when provided with --publication-url, --user-id, --max-body-bytes, --max-image-bytes, --substack-request-timeout-ms, and --confirmation-token-ttl-seconds, OAuth MCP_PUBLIC_BASE_URL, authorization-server URL, JWKS URL, optional documentation URL, and JWT algorithm configuration when AUTH_MODE=oauth, and Secret Manager-backed SUBSTACK_SESSION_TOKEN, PREVIEW_TOKEN_SECRET, optional MCP_PATH_SECRET when --mcp-path-secret is provided, and, for AUTH_MODE=static_bearer, MCP_BEARER_TOKEN. The generated cloud-run:plan follow-up passes the planned values to cloud-run:verify. When --mcp-path-secret is not provided, it verifies MCP_PATH_SECRET is absent from the deployment. Use the SERVICE_URL follow-ups to prove the deployed /healthz endpoint and auth-mode matching remote MCP smoke before recording gate 12. Add --evidence-artifact .data/v1/gate-12-cloud-run-deployment.md or --evidence-artifact .data/v1/gate-13-cloud-run-secrets.md to write a sanitized Markdown artifact with the verification result and the remaining Cloud Run manual checklist for gates 12 and 13.

The generated budget-alert follow-up requires BILLING_ACCOUNT_ID in the shell and creates a monthly 5USD budget scoped to projects/<project-id>. This is an operator command helper, not a server runtime secret:

BILLING_ACCOUNT_ID=<billing-account-id> gcloud billing budgets create \
  --billing-account "$BILLING_ACCOUNT_ID" \
  --display-name "substack-draft-mcp monthly budget" \
  --budget-amount 5USD \
  --calendar-period month \
  --filter-projects projects/your-gcp-project-id \
  --threshold-rule percent=0.50 \
  --threshold-rule percent=0.90 \
  --threshold-rule percent=1.00

After live Cloud Run acceptance traffic, export recent logs and scan them before recording gate 16 evidence:

mkdir -p .data && gcloud logging read 'resource.type="cloud_run_revision" AND resource.labels.service_name="substack-draft-mcp"' --format=json --limit=100 > .data/cloud-run-logs.json
npm run cloud-run:logs:verify -- --logs-json .data/cloud-run-logs.json --require-audit-events --evidence-artifact .data/v1/gate-16-cloud-run-logs.md

The log verifier reads exported JSON or newline-delimited JSON only. It does not call Cloud Run, read Secret Manager values, or print log entry contents. It fails if it sees cookie/bearer/secret-env patterns, private /mcp/<secret> path segments, sensitive auth field names, draft body/content fields, or mcp_audit objects with fields outside the allowed safe metadata set. Add --evidence-artifact .data/v1/gate-16-cloud-run-logs.md to write a sanitized Markdown artifact that summarizes counts, checks, finding categories, and structured manual log-review fields without raw log entries.

The default Secret Manager secret names are:

  • substack-session-token
  • mcp-preview-token-secret
  • mcp-bearer-token when using AUTH_MODE=static_bearer
  • mcp-path-secret when --mcp-path-secret mcp-path-secret is used

The generated deploy command follows this shape:

gcloud run deploy substack-draft-mcp \
  --source . \
  --region us-central1 \
  --allow-unauthenticated \
  --min-instances=0 \
  --max-instances=1 \
  --memory=512Mi \
  --cpu=1 \
  --timeout=60 \
  --set-env-vars="NODE_ENV=production,PORT=8080,LOG_LEVEL=info,MCP_TRANSPORT=http,SUBSTACK_PUBLICATION_URL=https://yourpublication.substack.com,SUBSTACK_USER_ID=123456,AUTH_MODE=static_bearer,MAX_BODY_BYTES=750000,MAX_IMAGE_BYTES=8000000,SUBSTACK_REQUEST_TIMEOUT_MS=30000,CONFIRMATION_TOKEN_TTL_SECONDS=900" \
  --set-secrets="SUBSTACK_SESSION_TOKEN=substack-session-token:latest,PREVIEW_TOKEN_SECRET=mcp-preview-token-secret:latest,MCP_BEARER_TOKEN=mcp-bearer-token:latest"

With --mcp-path-secret mcp-path-secret, the generated deploy command also adds MCP_PATH_SECRET=mcp-path-secret:latest to --set-secrets; do not put the path segment in --set-env-vars, docs, logs, or evidence.

Use AUTH_MODE=noauth on Cloud Run only for short-lived personal experiments. Durable ChatGPT use should use AUTH_MODE=oauth with a real OAuth/OIDC issuer and JWKS endpoint.

Environment

Run npm run auth:setup for local Substack authentication. Copy .env.example to .env only when you need to customize other settings or provide variables manually. Keep .env, .env.local, and .data/substack-auth.json private.

Important variables:

  • PORT: HTTP port, default 8787.
  • HOST: HTTP bind host. Defaults to 127.0.0.1 outside production and 0.0.0.0 when NODE_ENV=production.
  • MCP_TRANSPORT: must match the entrypoint: http for dev:http / start:http, stdio for dev:stdio / start:stdio.
  • MCP_PATH_SECRET: optional single URL-safe path segment that moves the HTTP MCP endpoint from /mcp to /mcp/<secret>. Slashes, spaces, ., and .. are rejected. Request and startup logs redact the configured secret segment as /mcp/<redacted>.
  • AUTH_MODE: noauth for local testing, static_bearer for clients that can send a bearer token, or oauth for JWT bearer-token verification.
  • MCP_BEARER_TOKEN: required when AUTH_MODE=static_bearer. Store the token value only, not an Authorization: header or Bearer ... prefix.
  • MCP_PUBLIC_BASE_URL: required when AUTH_MODE=oauth; public HTTPS origin of this MCP server. Embedded URL usernames/passwords are rejected.
  • OAUTH_AUTHORIZATION_SERVER_URL: required when AUTH_MODE=oauth; issuer URL that publishes OAuth or OIDC discovery metadata. Embedded URL usernames/passwords are rejected.
  • OAUTH_JWKS_URL: required when AUTH_MODE=oauth; JWKS endpoint used to verify JWT access-token signatures. Embedded URL usernames/passwords are rejected.
  • OAUTH_JWT_ALGORITHMS: optional comma-separated asymmetric JWT signing algorithms; defaults to RS256,ES256. Symmetric HS* algorithms and none are rejected.
  • OAUTH_RESOURCE_DOCUMENTATION_URL: optional documentation URL included in OAuth protected-resource metadata. Embedded URL usernames/passwords are rejected.
  • MAX_BODY_BYTES: max accepted body size for Markdown text, serialized blocks_v1 input, and requested get_draft body output.
  • MAX_IMAGE_BYTES: max accepted downloaded/decoded source size and final normalized PNG size before upload.
  • SUBSTACK_REQUEST_TIMEOUT_MS: positive timeout in milliseconds for outbound Substack API requests, including response body reads, and remote image fetches. Defaults to 30000.
  • PREVIEW_TOKEN_SECRET: HMAC secret for confirmation tokens. auth:setup generates it locally. Production startup rejects the built-in development default.
  • CONFIRMATION_TOKEN_TTL_SECONDS: positive confirmation token lifetime in seconds.
  • SUBSTACK_PUBLICATION_URL: public https:// publication origin used by the Substack API client and inspection script. Copied Substack post/editor paths, queries, and fragments are normalized to the origin.
  • SUBSTACK_SESSION_TOKEN: private Substack session cookie value only, not a full Cookie: header or connect.sid=... pair.
  • SUBSTACK_USER_ID: positive numeric user id for draft bylines. Browser-based auth:setup derives it from the authenticated publication dashboard; manual setup requires it as an explicit field.
  • SUBSTACK_USER_AGENT: optional browser-like user agent for Substack requests.

The validation and preview MCP tools do not call Substack. list_drafts, get_draft, create_draft, update_draft, upload_image, the inspection script, and the reusable API client do. AUTH_MODE=oauth exposes /.well-known/oauth-protected-resource, verifies JWT access tokens through JWKS, checks issuer/audience/expiry/scopes, and returns OAuth bearer challenges when authentication or tool scope checks fail.

Commands

npm run dev:http
MCP_TRANSPORT=stdio npm run dev:stdio
npm run cloud-run:logs:verify -- --logs-json .data/cloud-run-logs.json
npm run cloud-run:plan -- --project-id <id> --publication-url <url> --user-id <id>
npm run cloud-run:verify -- --service-json .data/cloud-run-service.json --auth-mode <mode> --publication-url <url> --user-id <id> --max-body-bytes 750000 --max-image-bytes 8000000 --substack-request-timeout-ms 30000 --confirmation-token-ttl-seconds 900 --evidence-artifact .data/v1/gate-12-cloud-run-deployment.md
npm run cloud-run:verify -- --service-json .data/cloud-run-service.json --auth-mode <mode> --publication-url <url> --user-id <id> --max-body-bytes 750000 --max-image-bytes 8000000 --substack-request-timeout-ms 30000 --confirmation-token-ttl-seconds 900 --evidence-artifact .data/v1/gate-13-cloud-run-secrets.md
npm run cloud-run:verify -- --service-json .data/cloud-run-service.json --auth-mode oauth --public-base-url <https://service-origin> --oauth-authorization-server-url <https://auth-origin> --oauth-jwks-url <https://auth-origin/jwks.json> --oauth-jwt-algorithms RS256,ES256 --evidence-artifact .data/v1/gate-12-cloud-run-deployment.md
npm run create:fixture -- --dry-run
npm run create:fixture -- --kind all --dry-run
npm run create:fixture -- --kind all --capture
npm run create:fixture -- --kind all --capture --fixture-dir fixtures/substack
npm run fixtures:status
npm run inspect:draft -- <draft_id>
npm run inspect:draft -- <draft_id> --include-raw --output .data/inspect-draft.json
npm run inspect:draft -- <draft_id> --fixture image --fixture-dir fixtures/substack
npm run smoke:docker-http
npm run smoke:http-local
npm run smoke:inspector
npm run smoke:http-oauth
npm run smoke:http-static-bearer
npm run smoke:stdio
: "${MCP_BEARER_TOKEN:?Set MCP_BEARER_TOKEN to the remote static bearer token first}" && MCP_BEARER_TOKEN="$MCP_BEARER_TOKEN" npm run smoke:remote -- --url https://<host>/mcp --evidence-artifact .data/v1/gate-14-static-bearer-remote.md
npm run smoke:ngrok-noauth
npm run smoke:ngrok-oauth
npm run smoke:ngrok-static-bearer
npm run smoke:remote-noauth -- --url https://<host>/mcp
: "${MCP_OAUTH_BEARER_TOKEN:?Set MCP_OAUTH_BEARER_TOKEN to a real access token first}" && MCP_OAUTH_BEARER_TOKEN="$MCP_OAUTH_BEARER_TOKEN" npm run smoke:remote-oauth -- --url https://<host>/mcp
npm run v1:preflight
npm run v1:preflight -- --json
npm run v1:preflight -- --fixture-dir fixtures/live
npm run v1:record -- --gate <id> --evidence <text>
npm run v1:record -- --gate <id> --fixture-dir fixtures/live --evidence <text>
npm run v1:runbook
npm run v1:runbook -- --gate 7
npm run v1:runbook -- --gate 7 --fixture-dir fixtures/live
npm run v1:runbook -- --gate 12 --mcp-path-secret mcp-path-secret
npm run v1:runbook -- --output .data/v1/manual-acceptance-runbook.md
npm run v1:runbook -- --write-artifacts
npm run v1:status
npm run v1:status -- --fixture-dir fixtures/live
npm run v1:status -- --evidence-file .data/v1-acceptance-evidence.json
npm run validate:v1-local
npm run start:http
MCP_TRANSPORT=stdio npm run start:stdio
npm run format:check
npm run lint
npm run typecheck
npm test
npm run test:watch
npm run mcp:preflight
npm run mcp:preflight -- --require-live
RUN_LIVE_SUBSTACK_TESTS=1 npm run test:live
npm run build
npm run coverage

The two mcp:preflight entries above are the verified focused local credential check. They do not replace the broader v1:preflight acceptance workflow. npm run test:live is guarded. Without RUN_LIVE_SUBSTACK_TESTS=1, the live suite is skipped; when enabled, it requires valid credentials from .env.local, .data/substack-auth.json, .env, or the current shell. The harness connects an MCP SDK Client to the registered MCP server through the SDK in-memory transport, calls client.listTools() and requires exactly validate_newsletter_content, preview_draft, create_draft, update_draft, list_drafts, get_draft, and upload_image, then invokes upload, preview, create, list, get, update, and post-update get through client.callTool. This covers MCP schemas, tool registration, and MCP result wrappers as well as real Substack behavior. It uploads a tiny image, creates an [MCP TEST] rich draft, keeps it unpublished, reads and updates that same draft without removing its rich content, and prints its ID/URL for manual Substack review and cleanup. Deterministic tests verify the MCP-boundary structure, shared local-auth loading, rich-content preservation, and guarded skip; the credentialed account flow remains unrun until local credentials are supplied. HTTP and stdio process transports are covered separately by npm run test:mcp-local. Cloud Run remains deferred.

Project Layout

  • src/ contains TypeScript source.
  • tests/ contains Vitest tests.
  • fixtures/ contains deterministic Markdown fixtures.
  • fixtures/substack/ is reserved for live draft-body fixtures from non-sensitive Substack test drafts.
  • docs/ contains the client setup and stable-endpoint guide.
  • examples/ contains MCP client configuration templates.
  • AGENTS.md contains shared coding-agent instructions.

Secret Rotation

Rotate secrets after any ngrok exposure, unexpected traffic, shared tunnel URL, remote-client exposure, or suspected local credential leak.

  • Rotate SUBSTACK_SESSION_TOKEN by invalidating or logging out of the browser session when possible, then copying a fresh connect.sid or substack.sid cookie value from a new browser session.
  • Rotate PREVIEW_TOKEN_SECRET with openssl rand -base64 32; this invalidates outstanding preview confirmation tokens immediately.
  • Rotate MCP_BEARER_TOKEN with openssl rand -base64 32, update any private remote MCP client that sends the bearer header, and remove the old value.
  • For Cloud Run, add new Secret Manager versions and redeploy or restart the service so the latest versions are loaded.

See SECURITY.md for the auth boundary and scripts/rotateLocalSecrets.md for the step-by-step local rotation runbook.

Troubleshooting

  • ChatGPT shows no tools: check that the URL ends in /mcp, the server is running, the ngrok tunnel is active, the client sends Accept: application/json, text/event-stream, CORS preflight allows authorization and mcp-protocol-version, and auth failures expose WWW-Authenticate.
  • Substack returns 401 or 403: refresh SUBSTACK_SESSION_TOKEN, prefer the canonical *.substack.com publication origin, and keep the browser-like SUBSTACK_USER_AGENT.
  • Draft formatting is wrong: capture a live draft with npm run inspect:draft -- <draft_id> and update the adapter fixture tests.
  • Image upload fails: inspect the returned error code and stage; then check source validity, MAX_IMAGE_BYTES, SUBSTACK_REQUEST_TIMEOUT_MS, remote public URL/DNS reachability, SVG safety restrictions, and Substack auth.

ScaffoldGuard

When available locally, run:

scaffold-guard check
scaffold-guard inspect-diff
scaffold-guard validate

Contributing

Contributions are welcome through fork-based pull requests. Read CONTRIBUTING.md before starting, and use the issue forms for bugs and feature proposals. Security vulnerabilities must be reported privately as described in SECURITY.md.

Project decisions and merge authority are documented in GOVERNANCE.md. All participants must follow the Code of Conduct. The project is licensed under Apache License 2.0.

from github.com/damienbenveniste/substack-mcp

Установка Substack Draft Server

У этого сервера нет опубликованного пакета — он собирается из исходников. Открой репозиторий и следуй инструкции в README.

▸ github.com/damienbenveniste/substack-mcp

FAQ

Substack Draft Server MCP бесплатный?

Да, Substack Draft Server MCP бесплатный — установка в пару кликов через Unyly без оплаты.

Нужен ли API-ключ для Substack Draft Server?

Нет, Substack Draft Server работает без API-ключей и переменных окружения.

Substack Draft Server — hosted или self-hosted?

Self-hosted: сервер запускается локально на твоей машине командой из раздела установки.

Как установить Substack Draft Server в Claude Desktop, Claude Code или Cursor?

Открой Substack Draft Server на unyly.org, выбери вкладку своего клиента (Claude Desktop, Claude Code, Cursor) и нажми Install — конфиг сгенерируется автоматически, без правки JSON.

Похожие MCP

Compare Substack Draft Server with

Не уверен что выбрать?

Найди свой стек за 60 секунд

Автор?

Embed-бейдж для README

Похожее

Все в категории development