Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ and the git tag history (`v0.1.0`–`v0.5.0`).

## [Unreleased]

### Fixed
- `get_post` retrieves published posts using the numeric-ID endpoint and unwraps
its `post` response (#125). Previously, numeric IDs were sent to the slug endpoint
and returned 404. The upstream `wordcount` field maps to the existing `word_count`
output. Missing or mismatched post data fails instead of returning empty success.

## [1.2.0] - 2026-09-14

### Added
Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/get-post.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { SubstackClient } from "../api/client.js";
import { createServer } from "../server.js";

const post = {
id: 42, title: "Published article", subtitle: null, slug: "published-article",
post_date: "2026-09-18T12:00:00Z", audience: "everyone", type: "newsletter",
body_html: "<p>Full article body.</p>", wordcount: 4,
canonical_url: "https://example.substack.com/p/published-article",
};
const envelope = { post, publication: { id: 7, subdomain: "example" }, publicationSettings: { unrelated: true } };
function mockRead(value: unknown = envelope, status = 200) {
const fetchMock = vi.fn(async () => new Response(JSON.stringify(value), { status }));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
}
afterEach(() => vi.unstubAllGlobals());

describe("published post lookup (#125)", () => {
it.each(["https://example.substack.com", "https://newsletter.example.com"])("uses the numeric-ID route and unwraps the post on %s", async origin => {
const read = mockRead();
const result = await new SubstackClient(origin, "synthetic-token", "1").getPost(42);
expect(result).toMatchObject({ id: 42, title: post.title, body_html: post.body_html, word_count: 4 });
expect(result).not.toHaveProperty("publicationSettings");
expect(result).not.toHaveProperty("post");
expect(read).toHaveBeenCalledTimes(1);
expect(read).toHaveBeenCalledWith(`${origin}/api/v1/posts/by-id/42`, expect.objectContaining({
redirect: "manual", headers: expect.objectContaining({ Cookie: "connect.sid=synthetic-token; substack.sid=synthetic-token;" }),
}));
});
it("preserves an existing word_count, including zero", async () => {
mockRead({ post: { ...post, word_count: 0 } });
expect((await new SubstackClient("https://example.substack.com", "synthetic-token", "1").getPost(42)).word_count).toBe(0);
});
it.each([null, {}, { post: null }, { post: { ...post, id: 43 } }])("rejects missing or mismatched post data: %j", async value => {
mockRead(value);
await expect(new SubstackClient("https://example.substack.com", "synthetic-token", "1").getPost(42)).rejects.toThrow("Unexpected published post response");
});
it.each([401, 403, 404, 429, 500])("preserves HTTP %i without retries or fallback scans", async status => {
const read = mockRead({ error: "Synthetic upstream error" }, status);
await expect(new SubstackClient("https://example.substack.com", "synthetic-token", "1").getPost(42)).rejects.toMatchObject({ statusCode: status });
expect(read).toHaveBeenCalledTimes(1);
});
it.each([false, true])("returns the established text and structured MCP output (multi=%s)", async multi => {
const read = mockRead();
const pubs = [{ key: "first", label: "First", client: new SubstackClient("https://first.example", "synthetic-token", "1") },
...(multi ? [{ key: "second", label: "Second", client: new SubstackClient("https://second.example", "synthetic-token", "2") }] : [])];
const server = createServer(pubs), client = new Client({ name: "post-regression", version: "1" });
const [ct, st] = InMemoryTransport.createLinkedPair();
await Promise.all([client.connect(ct), server.connect(st)]);
try {
const result = await client.callTool({ name: "get_post", arguments: { post_id: 42, ...(multi ? { publication: "second" } : {}) } });
expect(result.isError).not.toBe(true);
const expected = { id: 42, title: post.title, subtitle: null, slug: post.slug, post_date: post.post_date,
audience: "everyone", word_count: 4, body_html: post.body_html, url: post.canonical_url };
expect(result.structuredContent).toEqual(expected);
expect(JSON.parse((result.content as { text: string }[])[0].text)).toEqual(expected);
expect(read).toHaveBeenCalledWith(`https://${multi ? "second" : "first"}.example/api/v1/posts/by-id/42`, expect.anything());
} finally { await client.close(); await server.close(); }
});
});
12 changes: 10 additions & 2 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,9 +300,17 @@ export class SubstackClient {
}

async getPost(id: number): Promise<SubstackPost> {
return this.request<SubstackPost>(
`${this.publicationUrl}/api/v1/posts/${id}`,
// /posts/:slug treats a numeric ID as a slug and returns 404 (#125).
// The numeric route returns { post, publication, publicationSettings }.
const data = await this.request<{ post?: SubstackPost & { wordcount?: number } }>(
`${this.publicationUrl}/api/v1/posts/by-id/${id}`,
);
const post = data?.post;
if (!post || post.id !== id) {
throw new Error("Unexpected published post response; requested post cannot be verified.");
}
// This route calls the count `wordcount`; keep the public tool field stable.
return { ...post, word_count: post.word_count ?? post.wordcount };
}

async createDraft(
Expand Down
Loading