Skip to content

Support multiple Substack publications behind one server - #36

Open
caoimghgin wants to merge 1 commit into
conorbronsdon:mainfrom
caoimghgin:feat/multi-publication
Open

Support multiple Substack publications behind one server#36
caoimghgin wants to merge 1 commit into
conorbronsdon:mainfrom
caoimghgin:feat/multi-publication

Conversation

@caoimghgin

Copy link
Copy Markdown

Summary

  • Adds an opt-in SUBSTACK_PUB_<KEY>_* env var scheme so one server instance can hold credentials for several publications at once (e.g. SUBSTACK_PUB_KEVIN_MULDOON_PUBLICATION_URL / _SESSION_TOKEN / _USER_ID).
  • Single-publication mode is unchanged. With only the existing SUBSTACK_PUBLICATION_URL/SUBSTACK_SESSION_TOKEN/SUBSTACK_USER_ID set (or a single prefixed triplet), every tool's schema is byte-identical to before — no new parameter appears anywhere.
  • With 2+ publications configured, every tool gains a required publication parameter (a Zod enum of the configured keys). No silent default: two tools (create_note, create_note_with_link) publish immediately and irreversibly, so a design that guessed the wrong publication on those calls would be a real, uncorrectable mistake rather than an inconvenience. An unconfigured/missing value is rejected by the MCP SDK's own input validation before the handler — and therefore before any Substack API call — ever runs.
  • A malformed triplet (missing one of the three vars) fails fast at startup with a clear error naming the offending key, rather than silently dropping that publication. If legacy unprefixed vars are set alongside prefixed ones, they're ignored with a startup warning rather than treated as an implicit unnamed extra publication.
  • New src/auth/resolve-publications.ts reuses the existing resolveCredentials() (untouched) for the single-publication fallback path, rather than duplicating its logic.
  • The browser-login flow (substack-mcp-login, ~/.substack-mcp/session.json) is intentionally untouched — it stays single-session; multiple publications are env-var only for now.

Test plan

  • npm run lint — clean
  • npm run build — clean
  • npm test — 175/175 passing, including two new suites: resolve-publications.test.ts (env var parsing/fallback/incomplete-triplet/legacy-conflict behavior) and server-publications.test.ts (schema shape with 1 vs 2+ publications configured, invalid-value rejection before any network call, and correct client routing by key)
  • Manual smoke test against the built server: with only legacy vars set, confirmed tools/list shows no publication field anywhere and startup log lines match pre-change wording exactly; with three SUBSTACK_PUB_<KEY>_* triplets set, confirmed tools/list shows publication as required with the right 3-member enum and an unconfigured value is rejected before any network call

🤖 Generated with Claude Code

Adds an opt-in SUBSTACK_PUB_<KEY>_* env var scheme to configure several
publications at once. With exactly one publication configured (the
existing SUBSTACK_* vars, or a single prefixed triplet), every tool's
schema is unchanged. With two or more, every tool gains a required
`publication` enum parameter — no silent default, since two of the
tools (create_note, create_note_with_link) publish immediately and
irreversibly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@conorbronsdon conorbronsdon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes at eaa84bb9d99d74bf99e2f03fd8d5b37991e9bb74 for an unsafe configuration fallback in resolvePublications().

Named publication variables whose value is an empty string are skipped by if (!value) continue. If all three variables for a named publication are present but empty, the named group disappears and resolution silently falls back to stored legacy credentials. With one valid named publication plus a second all-empty group, the empty group also disappears, the server enters single-publication mode, and requests can silently route to the valid publication. For immediate Notes publishing, that can irreversibly publish to the wrong account.

Please treat the presence of any named-publication variable as configuration intent, retain the group, and fail validation when its required values are empty or incomplete. Add regression tests for both the all-empty-only case and the valid-A plus all-empty-B case, ensuring neither falls back or collapses to single-publication routing.

I reproduced the stored-credential fallback at the exact head and verified the finding with authenticated Claude Opus 5 and thinkingmachines/inkling:free. The implementation otherwise builds, lints, and its focused publication tests pass.

@conorbronsdon

Copy link
Copy Markdown
Owner

Concrete regression coverage I would add to src/__tests__/resolve-publications.test.ts:

it("does not fall back when a named publication is present but empty", () => {
  const loader = vi.fn(() => stored);
  const env = {
    SUBSTACK_PUB_SAPERE_PUBLICATION_URL: "",
    SUBSTACK_PUB_SAPERE_SESSION_TOKEN: "",
    SUBSTACK_PUB_SAPERE_USER_ID: "",
  } as NodeJS.ProcessEnv;

  expect(() => resolvePublications(env, loader)).toThrow(/sapere/i);
  expect(loader).not.toHaveBeenCalled();
});

Add the mixed case too: one complete SAPERE triplet plus an all-empty OTHER triplet must throw naming other; it must not return the one valid publication and thereby enable single-publication routing.

The implementation invariant is that a matching environment-variable name establishes named-publication intent. Create/retain that group before evaluating its value, then let the existing incomplete-group validation reject empty required fields.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants