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
24 changes: 24 additions & 0 deletions .changeset/governance-list-pagination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
---

feat(training-agent): add cursor-based pagination to list_content_standards, list_collection_lists, and list_property_lists handlers

Fifth entry in the rolling pagination conformance series (#3095, #3100, #3109, #3110). Adds full cursor-based pagination to the three governance list handlers that previously emitted no `pagination` block or a stub `{ has_more: false }`.

**Handler changes:**
- `handleListContentStandards` — reads `req.pagination?.max_results` (default 50, cap 100), decodes cursor via `decodeOffsetCursor('content_standards', ...)`, slices the filtered result set, and emits a full `pagination` envelope including `total_count` and a continuation `cursor` when `has_more: true`.
- `handleListCollectionLists` — replaces the `{ has_more: false }` stub with a real offset-cursor implementation using kind `'collection_lists'`.
- `handleListPropertyLists` — same pattern, kind `'property_lists'`.

**inputSchema updates:** `pagination` property added to the `list_content_standards`, `list_collection_lists`, and `list_property_lists` MCP tool definitions, and to `LIST_COLLECTION_LISTS_SCHEMA` in `framework-server.ts` (Zod-validated path) so callers receive the field in tool descriptions.

**Storyboards:** three new universal storyboards mirror the `pagination-integrity-list-accounts` pattern (create-then-list bootstrap, three items, `max_results=2` two-page walk):
- `static/compliance/source/universal/content-standards-pagination-integrity.yaml`
- `static/compliance/source/universal/collection-lists-pagination-integrity.yaml`
- `static/compliance/source/universal/property-lists-pagination-integrity.yaml`

**Doc-parity:** both `docs/building/conformance.mdx` and `docs/building/compliance-catalog.mdx` updated with rows for the three new storyboards.

Non-protocol change (training agent server-side behavior only, `--empty` changeset per playbook).

Closes #3112.
3 changes: 3 additions & 0 deletions docs/building/compliance-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ Every agent runs every storyboard in `/compliance/{version}/universal/` regardle
| `pagination-integrity-list-accounts` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_accounts` response — storyboard bootstraps three accounts via `sync_accounts`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. |
| `pagination-integrity-creative-formats` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_creative_formats` response — storyboard seeds two creative formats via `seed_creative_format`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. |
| `get-media-buys-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `get_media_buys` response — storyboard seeds three media buys via `seed_media_buy`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. |
| `content-standards-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_content_standards` response — storyboard bootstraps three content standards configurations via `create_content_standards`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. |
| `collection-lists-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_collection_lists` response — storyboard bootstraps three collection lists via `create_collection_list`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. |
| `property-lists-pagination-integrity` | `cursor` ↔ `has_more` invariant verified by walking a paginated `list_property_lists` response — storyboard bootstraps three property lists via `create_property_list`, then asserts page 1 is non-terminal and page 2 is terminal with no stale cursor. |
| `deterministic-testing` | `comply_test_controller` state-machine verification — skipped if `capabilities.compliance_testing.supported: false`. |
| `signed-requests` | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. |

Expand Down
3 changes: 3 additions & 0 deletions docs/building/conformance.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ Every agent MUST pass every storyboard below.
| [`pagination_integrity_list_accounts`](https://adcontextprotocol.org/compliance/latest/universal/pagination-integrity-list-accounts) | `cursor` ↔ `has_more` invariant on paginated `list_accounts` responses; storyboard bootstraps three accounts via `sync_accounts` and walks from a continuation page to a terminal page |
| [`pagination_integrity_creative_formats`](https://adcontextprotocol.org/compliance/latest/universal/pagination-integrity-creative-formats) | `cursor` ↔ `has_more` invariant on paginated `list_creative_formats` responses; storyboard seeds two creative formats via `seed_creative_format` and walks from a continuation page to a terminal page |
| [`get_media_buys_pagination_integrity`](https://adcontextprotocol.org/compliance/latest/universal/get-media-buys-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `get_media_buys` responses; storyboard seeds three media buys via `seed_media_buy` and walks from a continuation page to a terminal page |
| [`pagination_integrity_content_standards`](https://adcontextprotocol.org/compliance/latest/universal/content-standards-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_content_standards` responses; storyboard bootstraps three content standards configurations via `create_content_standards` and walks from a continuation page to a terminal page |
| [`pagination_integrity_collection_lists`](https://adcontextprotocol.org/compliance/latest/universal/collection-lists-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_collection_lists` responses; storyboard bootstraps three collection lists via `create_collection_list` and walks from a continuation page to a terminal page |
| [`pagination_integrity_property_lists`](https://adcontextprotocol.org/compliance/latest/universal/property-lists-pagination-integrity) | `cursor` ↔ `has_more` invariant on paginated `list_property_lists` responses; storyboard bootstraps three property lists via `create_property_list` and walks from a continuation page to a terminal page |
| [`deterministic_testing`](https://adcontextprotocol.org/compliance/latest/universal/deterministic-testing) | `comply_test_controller` state machine — skipped if `capabilities.compliance_testing.supported: false` |
| [`signed_requests`](https://adcontextprotocol.org/compliance/latest/universal/signed-requests) | RFC 9421 transport-layer request-signing verification — skipped if `request_signing.supported: false`. |

Expand Down
33 changes: 31 additions & 2 deletions server/src/training-agent/content-standards-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { randomUUID } from 'node:crypto';
import type { TrainingContext, ToolArgs, ContentStandardsState } from './types.js';
import { getSession, sessionKeyFromArgs, MAX_CONTENT_STANDARDS_PER_SESSION } from './state.js';
import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js';

// ── Tool definitions ─────────────────────────────────────────────

Expand Down Expand Up @@ -50,6 +51,13 @@ export const CONTENT_STANDARDS_TOOLS = [
channels: { type: 'array', items: { type: 'string' }, description: 'Filter by channel' },
languages: { type: 'array', items: { type: 'string' }, description: 'Filter by language' },
countries: { type: 'array', items: { type: 'string' }, description: 'Filter by country (ISO 3166-1 alpha-2)' },
pagination: {
type: 'object',
properties: {
max_results: { type: 'integer', minimum: 1, maximum: 100, description: 'Max standards per page (default 50, cap 100).' },
cursor: { type: 'string', description: 'Continuation token from a previous list_content_standards response.' },
},
},
},
},
},
Expand Down Expand Up @@ -221,7 +229,12 @@ export async function handleListContentStandards(
args: ToolArgs,
ctx: TrainingContext,
) {
const req = args as { channels?: string[]; languages?: string[]; countries?: string[] };
const req = args as {
channels?: string[];
languages?: string[];
countries?: string[];
pagination?: { max_results?: number; cursor?: string };
};
const session = await getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId));

let standards = [...session.contentStandards.values()];
Expand All @@ -244,8 +257,24 @@ export async function handleListContentStandards(
);
}

const totalMatching = standards.length;
const requestedMax = req.pagination?.max_results;
const maxResults = Math.min(typeof requestedMax === 'number' ? requestedMax : 50, 100);
const offset = decodeOffsetCursor('content_standards', req.pagination?.cursor);
if (offset === null) {
return { errors: [{ code: 'INVALID_REQUEST', message: 'pagination.cursor is malformed' }] };
}
const pageEnd = Math.min(offset + maxResults, totalMatching);
const pageStandards = standards.slice(offset, pageEnd);
const hasMore = pageEnd < totalMatching;

return {
standards: standards.map(toStandardsResponse),
standards: pageStandards.map(toStandardsResponse),
pagination: {
has_more: hasMore,
total_count: totalMatching,
...(hasMore && { cursor: encodeOffsetCursor('content_standards', pageEnd) }),
},
};
}

Expand Down
4 changes: 4 additions & 0 deletions server/src/training-agent/framework-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,10 @@ export function createFrameworkTrainingAgentServer(ctx: TrainingContext): AdcpSe
account: ACCOUNT_REF,
brand: BRAND_REF,
context: CONTEXT_REF,
pagination: z.object({
max_results: z.number().int().min(1).max(100).optional(),
cursor: z.string().optional(),
}).optional(),
};

const DELETE_COLLECTION_LIST_SCHEMA = {
Expand Down
30 changes: 29 additions & 1 deletion server/src/training-agent/inventory-governance-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto';
import type { TrainingContext, ToolArgs, CollectionListState } from './types.js';
import { getSession, sessionKeyFromArgs } from './state.js';
import { ACCOUNT_REF_SCHEMA } from './account-handlers.js';
import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js';

const MAX_ARRAY_INPUT = 100;

Expand Down Expand Up @@ -79,6 +80,13 @@ export const COLLECTION_LIST_TOOLS = [
properties: {
account: ACCOUNT_REF_SCHEMA,
name_contains: { type: 'string' },
pagination: {
type: 'object',
properties: {
max_results: { type: 'integer', minimum: 1, maximum: 100, description: 'Max lists per page (default 50, cap 100).' },
cursor: { type: 'string', description: 'Continuation token from a previous list_collection_lists response.' },
},
},
},
},
},
Expand Down Expand Up @@ -125,6 +133,7 @@ interface UpdateCollectionListInput extends ToolArgs {

interface ListInput extends ToolArgs {
name_contains?: string;
pagination?: { max_results?: number; cursor?: string };
}

interface DeleteInput extends ToolArgs {
Expand Down Expand Up @@ -226,7 +235,26 @@ export async function handleListCollectionLists(args: ToolArgs, ctx: TrainingCon
const filtered = input.name_contains
? lists.filter(l => l.name.toLowerCase().includes(input.name_contains!.toLowerCase()))
: lists;
return { lists: filtered, pagination: { has_more: false } };

const totalMatching = filtered.length;
const requestedMax = input.pagination?.max_results;
const maxResults = Math.min(typeof requestedMax === 'number' ? requestedMax : 50, 100);
const offset = decodeOffsetCursor('collection_lists', input.pagination?.cursor);
if (offset === null) {
return { errors: [{ code: 'INVALID_REQUEST', message: 'pagination.cursor is malformed' }] };
}
const pageEnd = Math.min(offset + maxResults, totalMatching);
const pageLists = filtered.slice(offset, pageEnd);
const hasMore = pageEnd < totalMatching;

return {
lists: pageLists,
pagination: {
has_more: hasMore,
total_count: totalMatching,
...(hasMore && { cursor: encodeOffsetCursor('collection_lists', pageEnd) }),
},
};
}

export async function handleDeleteCollectionList(args: ToolArgs, ctx: TrainingContext) {
Expand Down
31 changes: 29 additions & 2 deletions server/src/training-agent/property-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto';
import type { TrainingContext, ToolArgs, AccountRef, PropertyListState } from './types.js';
import { getSession, sessionKeyFromArgs, MAX_PROPERTY_LISTS_PER_SESSION } from './state.js';
import { ACCOUNT_REF_SCHEMA } from './account-handlers.js';
import { encodeOffsetCursor, decodeOffsetCursor } from './pagination.js';

const MAX_PROPERTIES_PER_LIST = 10_000;

Expand Down Expand Up @@ -46,6 +47,13 @@ export const PROPERTY_TOOLS = [
properties: {
account: ACCOUNT_REF_SCHEMA,
name_contains: { type: 'string', description: 'Filter to lists whose name contains this string' },
pagination: {
type: 'object',
properties: {
max_results: { type: 'integer', minimum: 1, maximum: 100, description: 'Max lists per page (default 50, cap 100).' },
cursor: { type: 'string', description: 'Continuation token from a previous list_property_lists response.' },
},
},
},
},
},
Expand Down Expand Up @@ -233,7 +241,10 @@ export async function handleListPropertyLists(
args: ToolArgs,
ctx: TrainingContext,
) {
const req = args as { name_contains?: string };
const req = args as {
name_contains?: string;
pagination?: { max_results?: number; cursor?: string };
};
const session = await getSession(sessionKeyFromArgs(args, ctx.mode, ctx.userId, ctx.moduleId));

let lists = [...session.propertyLists.values()];
Expand All @@ -243,8 +254,24 @@ export async function handleListPropertyLists(
lists = lists.filter(l => l.name.toLowerCase().includes(lowerFilter));
}

const totalMatching = lists.length;
const requestedMax = req.pagination?.max_results;
const maxResults = Math.min(typeof requestedMax === 'number' ? requestedMax : 50, 100);
const offset = decodeOffsetCursor('property_lists', req.pagination?.cursor);
if (offset === null) {
return { errors: [{ code: 'INVALID_REQUEST', message: 'pagination.cursor is malformed' }] };
}
const pageEnd = Math.min(offset + maxResults, totalMatching);
const pageLists = lists.slice(offset, pageEnd);
const hasMore = pageEnd < totalMatching;

return {
lists: lists.map(toListResponse),
lists: pageLists.map(toListResponse),
pagination: {
has_more: hasMore,
total_count: totalMatching,
...(hasMore && { cursor: encodeOffsetCursor('property_lists', pageEnd) }),
},
};
}

Expand Down
Loading
Loading