Skip to content

fix: cap pagination limit at Substack's 50, unbreaking get_post_analytics (#28) - #32

Merged
conorbronsdon merged 1 commit into
mainfrom
fix/pagination-limit
Aug 3, 2026
Merged

fix: cap pagination limit at Substack's 50, unbreaking get_post_analytics (#28)#32
conorbronsdon merged 1 commit into
mainfrom
fix/pagination-limit

Conversation

@conorbronsdon

Copy link
Copy Markdown
Owner

What & why

Closes #28. get_post_analytics failed on every call. It pages the published feed looking for the requested post ID, with a hardcoded pageSize = 100 — and Substack's post_management endpoints reject any limit above 50, so the first page request 400'd regardless of which ID was passed. The tool was unconditionally broken, not broken at the margin.

I probed the boundary directly against /api/v1/post_management/published:

limit Result
50 HTTP 200
51 HTTP 400 {"errors":[{"location":"query","param":"limit","value":51,"msg":"Invalid value"}]}
100 HTTP 400

51 is the exact boundary, so 50 is the largest page the API will serve. That is now an exported MAX_PAGE_SIZE, with the probe recorded in its doc comment.

Scan depth is preserved. Page bound goes 5 → 10, so MAX_PAGE_SIZE * ANALYTICS_MAX_PAGES is still 500. Worth being explicit about the request-count read, because 10 pages sounds worse than it is: the loop awaits each page in sequence, so this is 10 sequential requests worst case, not a parallel burst — and that worst case only occurs when the post ID isn't in the feed at all. A found post short-circuits on the page it lands on. The rate-limit exposure is much lower than the number suggests.

The list tools had the same cap wrong in two places. list_published_posts, list_drafts, and list_scheduled_posts clamped with Math.min(limit, 100) and described their limit as (1-100). The clamp only bites when a caller passes >50, which the default of 25 hid. The description is the worse half: it is what tells a model that 100 is a legal argument, so the wrong number there was actively generating the failing calls. Both now derive from MAX_PAGE_SIZE.

Kept clamping rather than switching to a zod .max(50), so any caller already passing 100 gets a smaller page instead of a brand-new validation error.

Derived, not restated. get_post_analytics stated "500 most recent posts" as a literal in two places (its description and its not-found note). That number is MAX_PAGE_SIZE * ANALYTICS_MAX_PAGES, so it is now interpolated from ANALYTICS_SCAN_DEPTH and can't rot the next time either constant moves.

About the tests

The existing suite passed 134/134 with the bug present. client.test.ts asserted only that the request URL contained /api/v1/post_management/published — a shape assertion, which cannot see a wrong value inside the shape it matched.

The new tests read the limit and offset values back off the URLs the client actually built, and drive the real registered tools over an in-memory MCP transport so the server-side clamp and the advertised cap are covered too (src/__tests__/server.test.ts is new — the clamp and the describe strings live in server.ts, out of reach of a client-only test).

I mutation-checked both before trusting them:

Revert pageSize to 100 → 3 failures, first one:

FAIL src/__tests__/client.test.ts > pagination limit cap (regression: #28)
     > getPostAnalytics sends a limit value within the cap on every page
AssertionError: expected 100 to be less than or equal to 50

The other two catch the knock-on effects: offsets tile at 100 instead of 50 (expected [0, 100, 200, …] to deeply equal [0, 50, 100, …]), and the short-circuit test drops from 2 requests to 1.

Revert the clamps and descriptions to 100 → 6 more failures:

AssertionError: expected 100 to be 50 // Object.is equality
AssertionError: expected 'Max posts to return (1-100; Substack …' to contain '1-50'

There are must-still-fire cases beside the must-not-fire ones: an under-cap limit: 7 must still pass through unchanged, and an over-cap limit: 100 must return a clamped result rather than an error.

How verified

  • npm run lint clean (tsc --noEmit, exit 0)
  • npm test passing — 150/150 across 8 files, up from 134 (+16)
  • npm run build clean
  • Mutation-tested: reverting either fix turns the new tests red, with the messages above

Safe-by-design checklist

  • No new publish / delete / schedule capability for long-form posts
  • Any immediate-publish behavior (Notes) stays loudly documented in the tool description

Version bumped to 0.6.1 in package.json and src/server.ts per the existing release convention (server.json is derived from package.json at publish time by #27's workflow), with a dated CHANGELOG entry. Note that merging this to main will trigger the trusted-publishing workflow.

🤖 Generated with Claude Code

…tics

get_post_analytics paged the published feed with a hardcoded pageSize=100.
Substack's post_management endpoints reject any limit above 50, so the very
first page request 400'd on every call regardless of post ID — the tool was
unconditionally broken, not broken at the margin.

Add an exported MAX_PAGE_SIZE = 50 recording the observed boundary (limit=50
-> 200, limit=51 -> 400 "Invalid value"), and raise the page bound from 5 to
10 so the documented 500-post scan depth survives. The loop awaits each page,
so this is 10 sequential requests worst case, not a parallel burst, and only
when the ID is absent from the feed entirely.

The list tools had the same cap wrong in two places: they clamped to 100, and
their limit descriptions advertised "1-100". The default of 25 hid the clamp,
but the description is what tells a model that 100 is a legal argument — that
was the part actively producing failing calls. Both now derive from
MAX_PAGE_SIZE. Clamping is kept rather than a zod .max(50) so callers already
passing 100 get a smaller page instead of a new error.

get_post_analytics's "500 most recent posts" is now derived from
MAX_PAGE_SIZE * ANALYTICS_MAX_PAGES in both the description and the not-found
note, so it can't rot when either constant moves.

Tests: the old suite passed with limit=100 on the wire because it only
asserted the URL contained the endpoint path. The new tests read the limit and
offset VALUES back off the URLs the client built, and drive the registered
tools over an in-memory MCP transport to cover the clamp and the advertised
cap. Both were mutation-checked: reverting pageSize to 100 fails with
"expected 100 to be less than or equal to 50"; reverting the clamps and
descriptions to 100 fails 6 more.

Closes #28

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@conorbronsdon
conorbronsdon merged commit 10138b8 into main Aug 3, 2026
2 checks passed
@conorbronsdon
conorbronsdon deleted the fix/pagination-limit branch August 3, 2026 08:49
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.

get_post_analytics: hardcoded pageSize=100 exceeds Substack API's accepted limit, causing 400

1 participant