fix: cap pagination limit at Substack's 50, unbreaking get_post_analytics (#28) - #32
Merged
Conversation
…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>
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Closes #28.
get_post_analyticsfailed on every call. It pages the published feed looking for the requested post ID, with a hardcodedpageSize = 100— and Substack'spost_managementendpoints reject anylimitabove 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{"errors":[{"location":"query","param":"limit","value":51,"msg":"Invalid value"}]}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_PAGESis still 500. Worth being explicit about the request-count read, because 10 pages sounds worse than it is: the loopawaits 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, andlist_scheduled_postsclamped withMath.min(limit, 100)and described theirlimitas(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 fromMAX_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_analyticsstated "500 most recent posts" as a literal in two places (its description and its not-found note). That number isMAX_PAGE_SIZE * ANALYTICS_MAX_PAGES, so it is now interpolated fromANALYTICS_SCAN_DEPTHand can't rot the next time either constant moves.About the tests
The existing suite passed 134/134 with the bug present.
client.test.tsasserted 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
limitandoffsetvalues 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.tsis new — the clamp and thedescribestrings live inserver.ts, out of reach of a client-only test).I mutation-checked both before trusting them:
Revert
pageSizeto 100 → 3 failures, first one: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:
There are must-still-fire cases beside the must-not-fire ones: an under-cap
limit: 7must still pass through unchanged, and an over-caplimit: 100must return a clamped result rather than an error.How verified
npm run lintclean (tsc --noEmit, exit 0)npm testpassing — 150/150 across 8 files, up from 134 (+16)npm run buildcleanSafe-by-design checklist
Version bumped to 0.6.1 in
package.jsonandsrc/server.tsper the existing release convention (server.jsonis derived frompackage.jsonat publish time by #27's workflow), with a dated CHANGELOG entry. Note that merging this tomainwill trigger the trusted-publishing workflow.🤖 Generated with Claude Code