diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c77a8..fc13edb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/__tests__/get-post.test.ts b/src/__tests__/get-post.test.ts new file mode 100644 index 0000000..adbd5e1 --- /dev/null +++ b/src/__tests__/get-post.test.ts @@ -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: "

Full article body.

", 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(); } + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 973233c..0868d7a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -300,9 +300,17 @@ export class SubstackClient { } async getPost(id: number): Promise { - return this.request( - `${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(