MCP/API paging traps: pageNum documented 1-based but is 0-based, pageSize cap doesn't clamp the offset, and playout rows carry no channelId
#616
Closed
opened 2026-07-25 17:14:57 +02:00 by timothy
·
3 comments
No Branch/Tag Specified
main
901-pin-the-artifact-whole
renovate/meziantou.analyzer-3.x
release/v26.15.0-notes
renovate/lucene.net
renovate/cliwrap-3.x
issue-806-guard-populations
renovate/dotnet-monorepo
scratch/767b-poisoned
scratch/767b-control
release/v26.14.0-notes
release/v26.14.0
renovate/sqlitepclraw.bundle_e_sqlite3-3.x
docs/510-skill-logo-bug-policy
fix/510-watermark-resolution-policy
fix/629-verdict-classifier-falseopens
fix/609-decisions-edit-token-scope
issue-135-clear-to-none
release/v26.12.0-notes
fix/409b-lastscan-api-parity
fix/401-updatechannel-mirror-422
fix/327-playlist-rename-validation
fix/410-scancancel-log-level
fix/409-447-librariesscreen-neverscanned
fix/338-zap-exit-code
fix/367-plex-budget-message
fix/310-debom-legacy-cs
ci/604-lane-rebalance
feat/388-design-mirror
feat/247-test-ownership
feat/247-primary-action
feat/357-player-owned-playback
feat/357-jellyfin-plugin-poc
fix/289-mcp-hardening
issue58-mcp
feat/244-channels-extract
ci/auto-bump-prod-compose
feat/multi-rerun-collections-api
feat/collections-api
feat/quick-wins
feat/185-docs-part2
feat/140-collections-screen
feat/146-channel-edit
feat/147-classic-ui-link
issue22-renovate-dashboard
feat/91-cutover
feat/63-composite-create
feat/65-library-browse
feat/85-epg
feat/86-schedule-editor
feat/109-dashboard-data
feat/99-session-tracking
fix/dockerfile-node-tag
feat/59-spa-foundation
docs/59-ui-redesign-brief
feat/102-json-guide
feat/111-schedule-durations
feat/104-artwork-upload
feat/103-media-sources-api
feat/playouts-read-api
feat/108-health-api
feat/105-picker-list-endpoints
issue-97-channel-state-api
issue42-jellyfin-musicvideos
issue46-rest-api-error-contract
dependabot/nuget/ErsatzTV.FFmpeg.Tests/multi-d307a2e06f
qsv-improvements
hdr-vulkan-cuda-test
v26.15.0
v26.14.0
v26.13.0
v26.12.0
v26.11.0
v26.10.0
v26.9.0
v26.8.0
v26.7.0
blazor-final
v26.6.0
v26.5.0
v26.4.0
v26.3.1
v26.3.0
v26.2.0
v26.1.1
v26.1.0
v25.9.0
v25.8.0
v25.7.1
v25.7.0
v25.6.0
v25.5.0
v25.4.0
v25.3.1
v25.3.0
v25.2.0
v25.1.0
v0.8.8-beta
v0.8.7-beta
v0.8.6-beta
v0.8.5-beta
v0.8.4-beta
v0.8.3-beta
v0.8.2-beta
v0.8.1-beta
v0.8.0-beta
v0.7.9-beta
v0.7.8-beta
v0.7.7-beta
v0.7.6-beta
v0.7.5-beta
v0.7.4-beta
v0.7.3-beta
v0.7.2-beta
v0.7.1-beta
v0.7.0-beta
v0.6.9-beta
v0.6.8-beta
v0.6.7-beta
v0.6.6-beta
v0.6.5-beta
v0.6.4-beta
v0.6.3-beta
v0.6.2-beta
v0.6.1-beta
v0.6.0-beta
v0.5.8-beta
v0.5.7-beta
v0.5.6-beta
v0.5.5-beta
v0.5.4-beta
v0.5.3-beta
v0.5.2-beta
v0.5.1-beta
v0.5.0-beta
v0.4.5-alpha
v0.4.4-alpha
v0.4.3-alpha
v0.4.2-alpha
v0.4.1-alpha
v0.4.0-alpha
v0.3.8-alpha
v0.3.7-alpha
develop
v0.3.6-alpha
v0.3.5-alpha
v0.3.4-alpha
v0.3.3-alpha
v0.3.2-alpha
v0.3.1-alpha
v0.3.0-alpha
v0.2.5-alpha
v0.2.4-alpha
v0.2.3-alpha
v0.2.2-alpha
v0.2.1-alpha
v0.2.0-alpha
v0.1.5-alpha
v0.1.4-alpha
v0.1.3-alpha
v0.1.2-alpha
v0.1.1-alpha
v0.1.0-alpha
v0.0.62-alpha
v0.0.61-alpha
v0.0.60-alpha
v0.0.59-alpha
v0.0.58-alpha
v0.0.57-alpha
v0.0.56-alpha
v0.0.55-alpha
v0.0.54-alpha
v0.0.53-alpha
v0.0.52-alpha
v0.0.51-alpha
v0.0.50-alpha
v0.0.49-prealpha
v0.0.48-prealpha
v0.0.47-prealpha
v0.0.46-prealpha
v0.0.45-prealpha
v0.0.44-prealpha
v0.0.43-prealpha
v0.0.42-prealpha
v0.0.41-prealpha
v0.0.40-prealpha
v0.0.39-prealpha
v0.0.38-prealpha
v0.0.37-prealpha
v0.0.36-prealpha
v0.0.35-prealpha
v0.0.34-prealpha
v0.0.33-prealpha
v0.0.32-prealpha
v0.0.31-prealpha
v0.0.30-prealpha
v0.0.29-prealpha
v0.0.28-prealpha
v0.0.27-prealpha
v0.0.26-prealpha
v0.0.25-prealpha
v0.0.24-prealpha
v0.0.23-prealpha
v0.0.22-prealpha
v0.0.21-prealpha
v0.0.20-prealpha
v0.0.19-prealpha
v0.0.18-prealpha
v0.0.17-prealpha
v0.0.16-prealpha
v0.0.15-prealpha
v0.0.14-prealpha
v0.0.13-prealpha
v0.0.12-prealpha
v0.0.11-prealpha
v0.0.10-prealpha
v0.0.9-prealpha
v0.0.8-prealpha
v0.0.7-prealpha
v0.0.6-prealpha
v0.0.5-prealpha
v0.0.4-prealpha
v0.0.3-prealpha
v0.0.2-prealpha
v0.0.1-prealpha
Labels
Clear labels
ad-hoc
api
bug
ci-cd
content
dependencies
enhancement
frontend
in-progress
jellyfin
parked
priority: high
priority: low
priority: medium
review
security
One-off / ad-hoc work not tracked by a dedicated issue
REST API / HTTP endpoints
Something isn't working
Build, test, deploy pipeline
Channel content / schedules / playlists
Dependency updates (Renovate)
New feature or improvement
ChicoryTV React SPA frontend
Claimed by an active session — do not pick up
Jellyfin tuner / IPTV integration
Excluded from automatic queue pickup; work only when explicitly selected
Adversarial review finding
Security / vulnerability fix
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: timothy/ersatztv#616
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found driving 11 collections / 1,184 item-adds through the MCP write path in #487. Full write-up in #58 (closed, so filing the actionable parts here).
All three fail silently and plausibly, which is what makes them worth fixing.
1.
pageNumis 0-based but documented as 1-based (highest value)ErsatzTV.Mcp/ToolCatalog.cs:235:It is actually 0-based. A caller that trusts the description and starts at
pageNum=1:No error, no warning — just a short set. It reads exactly like data loss, and I burned a verification pass chasing it as one.
Fix: either correct the description to "0-based", or make the parameter 1-based to match the description. Prefer the latter (1-based is the least-surprise convention and
/api/v1is additive-only, but this is a fix to an under-specified param rather than a contract break — worth a judgment call).2.
pageSizeis capped for the returned page, but the offset honors the requested valueThe returned page is capped at 100, but the offset arithmetic still uses ~500, so page 2 lands past item 200 instead of at item 101. An over-large
pageSizesilently changes which items you get, not just how many.Fix: clamp
pageSizefirst, then derive the offset from the clamped value.3.
reset_channel_playouttakes a channel id;list_playoutsexposes no channel idersatztv_list_playoutsrows are{id, channelNumber, channelName, scheduleKind, buildStatus, …}— theidis the playout id.ersatztv_reset_channel_playouttakes a channel id. Nothing in the playout row lets you get from one to the other, and the two id spaces overlap numerically, so passing the playout id succeeds against the wrong entity.I hit this live: resetting "playout 23" reset channel 23 (
Reality TV) instead of ch400Music Video Mix. Harmless here (a rebuild), but the same confusion on a destructive tool would not be.Fix: add
channelIdto the playout list/detail rows (additive, allowed post-freeze), and/or rename the tool arg tochannelIdso the schema names what it wants.Not included here
Two additive-surface items from the same run are deliberately left out as larger design questions — see #58: no exact-tag match operator (
tag:is analyzed, sotag:"Electronic"also matchesX-mix 5 - Mr. C - The Electronic Storm), and the pagedlist_*MCP tools still exposing nopageNum/pageSize.Done-when
pageNumsemantics and its description agree; a test pins which — API is 0-based;ToolCatalogTests.Paged_Tools_Should_Document_PageNum_As_Zero_Basedpins it for every paged tool (mutation-verified)pageSizeover-cap clamps the offset coherently; a test pins page-2 contents at an over-largepageSize— it already did on all 12 endpoints;GetCollectionItemsHandlerTests.Handle_Should_Derive_Offset_From_The_Clamped_PageSizepins it (mutation-verified: the raw-offset mutation returns an empty page)channelId(or the reset arg is renamed), OpenAPI artifacts regenerated — list rows since #297; detail DTO added here; the reset arg description now names the id-space trap;v1.json+v1.d.tsregeneratedReview-verdict: MERGEABLE @ 78ec997eClaiming this one (Claude Code, Opus 5 orchestrator session 2026-07-25).
Selector's top pick #629 is blocked behind PR #630, so this is the top eligible backlog item. No
api-labelled siblings are open, so it runs solo.Scope as filed: all three traps — (1)
pageNumbase mismatch, (2)pageSizeclamp-before-offset, (3)channelIdon playout rows.Scope correction after verifying each claim against the code
Working this on
fix/616-paging-traps. Only one of the three filed traps was real as described — recording the evidence here since two of them will otherwise be re-derived by the next reader.pageNumdocumented 1-based, actually 0-basedpageSizecapped but offset honors the raw valuechannelIdOn (2). All 12 paged endpoints clamp before passing, and every handler skips by the clamped size;
GetCollectionItemsHandlerre-clamps defensively. The reported observation —pageSize=500&pageNum=2on a 204-item collection returning 4 items — is exactly correct 0-based behaviour at the clamped width of 100: page 2 is items 201–204. This issue's own trap-1 table states the same arithmetic. A cold cross-family review independently enumerated all 12 endpoints and reached the same conclusion. So it is pinned by a mutation-verified test rather than "fixed".On (3).
PlayoutListItemResponseModelgainedChannelIdin1a3c8e27(#297, 2026-07-22) — three days before this issue was filed. The report was measured against prod, which tracks the older:prodimage, so it was true when observed and stale when filed. The detail DTOPlayoutResponseModelgenuinely still lacked it; that is what the PR adds.On (1) — the judgment call. The issue leaned toward making the parameter 1-based to match the description. I did the opposite and corrected the description, because
/api/v1is additive-only post-freeze (api.versioning-v1), 0-based is load-bearing in a dozen controllers plus the SPA, and a 1-based wrapper over a 0-based API would make the same parameter name mean two different things on two surfaces a reader routinely reads together. Rationale is recorded in the newapi.paging-zero-based.What the review then found that the issue didn't
SchedulesScreen.tsxrequested the rerun-collection picker withpageNum: 1, so it skipped the first page: with ≤100 rerun collections the picker was served an empty page and silently offered nothing. Fixed, with a mutation-verified vitest.ersatztv_list_playoutsandersatztv_get_playout_items. SinceToolArgumentValidatorrejects undeclared arguments, an agent was hard-capped at the first 100 rows with no way to ask for more. Both now takePage().Deferred, tracked
pageNumparameters carry no description, so the 0-based contract is invisible to REST consumers. The decision record names this as a known gap rather than claiming coverage it doesn't have.pageSize: 1000against a 100 cap, so it truncates above 100 rerun collections.Closing record
Outcome: Merged as PR #635 (
f394d6ce). Of the three filed traps, one was real, one was not reproducible, and one was already fixed onmainbefore this issue was written. The fix that shipped is therefore mostly documentation of an existing convention plus two defects the issue did not contain: a live 1-based caller in the SPA and two MCP tools that silently hard-capped paging.Root cause:
pageNumis 0-based across all 12 paged/api/v1endpoints, and that convention was correct in code but written down nowhere. The MCP tool catalog therefore described it as "1-based", and a caller trusting the description skipped the first page — returning a short set with no error, which reads as data loss rather than an off-by-one (it cost #487 a verification pass). The same unwritten convention had already produced a second, unnoticed casualty inSchedulesScreen.tsx.Decisions/conventions changed: added
api.paging-zero-based(0-based everywhere; the offset derives from the EFFECTIVE page size, never the requested one; the cap is per-endpoint — 100 typical, 200 auto-tune members, 1000 search/all-items — and must not be documented as one number).Reusable knowledge:
pageSize=500&pageNum=2returning 4 items of 204 is exactly correct 0-based behaviour at the clamped width of 100. Trap 3 was measured against prod, which tracks the older:prodimage — it was true when observed and stale when filed. Re-verify a bug report againstmainbefore fixing it, and treat a prod-measured report as dated by the image, not by the report.ToolCatalogTestsversion filtered on tools that already declaredpageNum, so a tool wrapping a paged endpoint with no paging args escaped it entirely — exactly the defect present. Non-emptiness was not enough; it now pins the expected set by name.intlanding in a differentintslot compiles clean. Enumerate every construction site (here: 2).Verification: .NET 1900 + MCP 59; web 996 across 105 files;
tsc, eslint, format gate, BOM check all clean. All three behavioural tests mutation-verified — each fails when its fix is reverted. Cold cross-family adversarial review over three rounds (Review-verdict: MERGEABLE @ 78ec997e). CI green on every required check.Deferred: #633 (the 12 OpenAPI
pageNumparameters carry no description, so the 0-based contract is invisible to REST consumers — the record names this as a known gap rather than claiming coverage it lacks) and #634 (the schedules picker still askspageSize: 1000against a 100 cap, so it truncates above 100 rerun collections). Also noted but not filed:ToolCatalogTestscompares tool names rather thanPathTemplateagainst the paged-endpoint set, so a future paged tool withoutPage()needs the expected-set edit to catch it.Docs updated:
docs/decisions/records/api/paging-zero-based.md(new),docs/decisions/README.md(regenerated),docs/api-conventions.md,docs/mcp.md, plus regeneratedErsatzTV/wwwroot/openapi/v1.jsonandweb/src/api/generated/v1.d.ts.