Decision: defer. The 1.x contract already covers two maintained surfaces: MCP tools and the operator CLI. A documented JavaScript API would add a third, with its own type snapshots, semantic-versioning reviews and support questions. No issue, discussion or support request currently asks for programmatic reuse. The design below is the one to adopt when that demand appears.
package.json declares bin only: no main, exports or types. The build
emits declaration files into dist/, but nothing presents them as a contract.
A consumer can only deep-import internal dist/... paths, which may change in
any release. The repository's own scripts use relative ../dist/... imports;
those are development tooling, not a supported interface.
Several modules are unsafe as library entry points:
dist/index.jscallsrun()when loaded. It readsprocess.argvand starts the stdio MCP server or a CLI command.resolvePublications()reads environment variables and stored session files.SubstackClientperforms no network I/O until a method is called. Its methods mix raw upstream shapes (for example drafts withdraft_titleanddraft_body) with partially projected results (subscriber counts, publication metadata). The complete documented output contract, including bounds and failure codes, is enforced only at the MCP tool boundary.
createServer(publications) builds an MCP server without connecting a transport.
The operator CLI already calls tools through an in-memory client/server pair to
reuse those projections.
Add one subpath, @conorbronsdon/substack-mcp/api, backed by a new entry file
with no import-time side effects:
convertMarkdown(markdown, "draft" | "note"): pure; returns the ProseMirror document andunsupported_nodesdiagnostics, so callers can preview conversion losses before any write.createToolClient({ publicationUrl, sessionToken, userId, userAgent?, timeoutMs? }): a thin façade overcreateServerand the in-memory transport.callTool(name, args)returns the tool's parsed JSON result, or throws a typed failure carrying the samecode,status,status_sourceandretry_afterprojection the MCP boundary already emits.
Draft checks go through callTool("preflight_draft", { draft_id }). The internal
preflightDraft helper is not exported: it reads the raw draft fields, not the
projected get_draft result, so exposing it would need its own public input
shape.
SubstackClient should not be exported either. Its mixed raw and projected
return values would become a compatibility promise, or need a second projection
layer that duplicates the tool handlers.
Adding any exports map also blocks deep imports of unlisted paths for package
consumers. That is intended, but it is a visible change for anyone relying on
dist/... today and must be called out in the changelog. The bin entries are
unaffected.
Read one page of drafts:
import { createToolClient } from "@conorbronsdon/substack-mcp/api";
const substack = createToolClient({
publicationUrl: "https://example.substack.com",
sessionToken: mySecretStore.get("substack-session"),
userId: "12345",
});
const drafts = await substack.callTool("list_drafts", { offset: 0, limit: 10 });Prepare a private draft after inspecting conversion losses, then check it:
import { readFile } from "node:fs/promises";
import { convertMarkdown, createToolClient } from "@conorbronsdon/substack-mcp/api";
const markdown = await readFile("post.md", "utf8");
const { unsupported_nodes } = convertMarkdown(markdown, "draft");
if (unsupported_nodes.length > 0) throw new Error("Review unsupported Markdown first");
const substack = createToolClient({ publicationUrl, sessionToken, userId });
const draft = await substack.callTool("create_draft", { title: "Post title", body: markdown });
const review = await substack.callTool("preflight_draft", { draft_id: draft.id });
// Review the draft in Substack's editor. Long-form posts are never published here.create_draft converts the Markdown itself; the local conversion call is only a
preview of its diagnostics.
| Concern | Rule |
|---|---|
| Credentials | Explicit arguments only. The API never reads environment variables or stored sessions. |
| Import | No transport, credential loading, network request or console output on import. |
| Output | The documented MCP tool result for each tool; no new shapes. |
| Errors | Typed failure with the existing static projection. No upstream bodies, cookies or arbitrary exception text. |
| Retries | None. A failed write may have succeeded; the caller reconciles before any explicit retry. |
| Side effects | Unchanged per tool: reads, private draft writes, public image upload, immediate public Notes and consented subscriber writes. |
| Versioning | Exported names and types join the 1.x interface in the tool contract, with type snapshots reviewed like the output-contract snapshots. |
No Python wrapper, alternative registry or package split. Long-form publishing, deletion and scheduling remain out of scope for every surface. Notes keep their immediate-publication semantics.
- Add
exports(./apiand./package.json) andtypestopackage.json, keepingbin. - Add a side-effect-free
src/api.tsthat exports onlyconvertMarkdownandcreateToolClient. - Extend
npm run test:package: install the packed tarball in a clean temporary directory, import the subpath with no credentials or network access, assert no output on import, and type-check a small consumer file. - Add type snapshots and document the surface in the tool contract.
- Record the deep-import change in the changelog under a minor release.
- An issue or discussion describes a concrete scripted workflow that the MCP tools and CLI cannot serve.
- A bug report shows people depending on deep
dist/...imports. - A maintained downstream project needs the in-memory tool pattern and would otherwise copy it.