posterly-mcp-server 0.48.5 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +8 -2
  2. package/dist/lib/api-client.d.ts +80 -12
  3. package/dist/lib/api-client.js +40 -15
  4. package/dist/lib/format.d.ts +39 -2
  5. package/dist/lib/format.js +158 -0
  6. package/dist/lib/version.d.ts +1 -1
  7. package/dist/lib/version.js +1 -1
  8. package/dist/tools/audit-google-business-profile.d.ts +2 -2
  9. package/dist/tools/connect-account.d.ts +2 -2
  10. package/dist/tools/create-post.d.ts +12 -12
  11. package/dist/tools/create-posts-batch.d.ts +8 -8
  12. package/dist/tools/create-webhook.d.ts +2 -2
  13. package/dist/tools/disconnect-account.d.ts +2 -2
  14. package/dist/tools/find-slot.d.ts +2 -2
  15. package/dist/tools/get-updates.d.ts +4 -0
  16. package/dist/tools/get-updates.js +13 -1
  17. package/dist/tools/list-accounts.d.ts +2 -2
  18. package/dist/tools/list-activity.d.ts +4 -0
  19. package/dist/tools/list-activity.js +16 -3
  20. package/dist/tools/list-analytics.d.ts +2 -2
  21. package/dist/tools/list-comments.d.ts +2 -2
  22. package/dist/tools/list-conversations.d.ts +2 -2
  23. package/dist/tools/list-google-business-media.d.ts +2 -2
  24. package/dist/tools/list-google-business-reviews.d.ts +4 -4
  25. package/dist/tools/list-media.d.ts +4 -0
  26. package/dist/tools/list-media.js +22 -1
  27. package/dist/tools/list-platforms.d.ts +4 -4
  28. package/dist/tools/list-post-suggestions.d.ts +4 -0
  29. package/dist/tools/list-post-suggestions.js +16 -4
  30. package/dist/tools/list-posts.d.ts +35 -17
  31. package/dist/tools/list-posts.js +63 -33
  32. package/dist/tools/manage-comment.d.ts +2 -2
  33. package/dist/tools/manage-conversation.d.ts +4 -4
  34. package/dist/tools/manage-google-business-media.d.ts +8 -8
  35. package/dist/tools/manage-google-business-review.d.ts +10 -10
  36. package/dist/tools/manage-publishing-pause.d.ts +2 -2
  37. package/dist/tools/manage-webhook.d.ts +4 -4
  38. package/dist/tools/validate-post.d.ts +8 -8
  39. package/package.json +1 -1
  40. package/server.json +2 -2
  41. package/src/lib/api-client.ts +94 -17
  42. package/src/lib/format.ts +201 -1
  43. package/src/lib/version.ts +1 -1
  44. package/src/tools/get-updates.ts +15 -1
  45. package/src/tools/list-activity.ts +18 -3
  46. package/src/tools/list-media.ts +23 -2
  47. package/src/tools/list-post-suggestions.ts +17 -4
  48. package/src/tools/list-posts.ts +74 -45
  49. package/tool-registry.json +110 -5
package/src/lib/format.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, LearnedVoice, Post } from './api-client.js';
1
+ import type { Account, LearnedVoice, ListPostsResponse, Post, PostCountsResponse, PostsRangeEcho } from './api-client.js';
2
2
 
3
3
  type CellValue = string | number | boolean | null | undefined;
4
4
 
@@ -244,3 +244,203 @@ function isInCurrentMonth(value?: string | null): boolean {
244
244
  const now = new Date();
245
245
  return date.getFullYear() === now.getFullYear() && date.getMonth() === now.getMonth();
246
246
  }
247
+
248
+ /**
249
+ * The completeness line every paged list prints, so an agent always knows
250
+ * whether it has seen everything. Shared by the npm package and the hosted
251
+ * endpoint.
252
+ */
253
+ export function formatPagingNote(input: {
254
+ noun: string;
255
+ tool: string;
256
+ offset?: number | null;
257
+ count: number;
258
+ total?: number | null;
259
+ hasMore: boolean;
260
+ nextOffset?: number | null;
261
+ }): string {
262
+ const offset = input.offset ?? 0;
263
+ const first = offset + 1;
264
+ const last = offset + input.count;
265
+ const ofTotal = typeof input.total === 'number' ? ` of ${input.total}` : '';
266
+ if (input.hasMore) {
267
+ const next = typeof input.nextOffset === 'number' ? input.nextOffset : last;
268
+ return `**Not complete: showing ${first}-${last}${ofTotal} ${input.noun}.** More available: call ${input.tool} again with the same filters and offset=${next}. Keep paging until this says complete before drawing any conclusion about totals.`;
269
+ }
270
+ if (offset === 0) {
271
+ return `**Complete: all ${typeof input.total === 'number' ? input.total : input.count} ${input.noun} are shown.**`;
272
+ }
273
+ return `**Complete: this is the last page (${input.noun} ${first}-${last}${ofTotal}).**`;
274
+ }
275
+
276
+ // ---------------------------------------------------------------------------
277
+ // list_posts: shared by the npm package and the hosted endpoint
278
+ // (lib/mcp/http-tools.ts imports these), so both surfaces print the same
279
+ // completeness line and the same times for the same response.
280
+ // ---------------------------------------------------------------------------
281
+
282
+ export type PostListRange = PostsRangeEcho;
283
+ export type PostListPage = ListPostsResponse;
284
+ export type { PostCountsResponse };
285
+
286
+ function zonedParts(value: string | Date, timeZone: string): Record<string, string> {
287
+ const date = typeof value === 'string' ? new Date(value) : value;
288
+ const parts = new Intl.DateTimeFormat('en-US', {
289
+ timeZone,
290
+ weekday: 'short',
291
+ month: 'short',
292
+ day: 'numeric',
293
+ year: 'numeric',
294
+ hour: '2-digit',
295
+ minute: '2-digit',
296
+ second: '2-digit',
297
+ hourCycle: 'h23',
298
+ }).formatToParts(date);
299
+ return Object.fromEntries(parts.map((part) => [part.type, part.value]));
300
+ }
301
+
302
+ /** "Wed, Oct 1, 2026, 09:00" in `timeZone`; the raw value when it is not a date. */
303
+ export function formatDateTimeInZone(value: string | null | undefined, timeZone: string): string {
304
+ if (!value) return 'not scheduled';
305
+ const date = new Date(value);
306
+ if (Number.isNaN(date.getTime())) return value;
307
+ const p = zonedParts(date, timeZone);
308
+ return `${p.weekday}, ${p.month} ${p.day}, ${p.year}, ${p.hour}:${p.minute}`;
309
+ }
310
+
311
+ /** "Wed, Oct 1, 2026" for a YYYY-MM-DD calendar day. */
312
+ export function formatCalendarDay(day: string): string {
313
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
314
+ if (!match) return day;
315
+ const p = zonedParts(new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 12)), 'UTC');
316
+ return `${p.weekday}, ${p.month} ${p.day}, ${p.year}`;
317
+ }
318
+
319
+ function isZonedMidnight(value: string, timeZone: string): boolean {
320
+ const p = zonedParts(value, timeZone);
321
+ return p.hour === '00' && p.minute === '00' && p.second === '00';
322
+ }
323
+
324
+ function zonedDay(value: string | Date, timeZone: string): string {
325
+ const p = zonedParts(value, timeZone);
326
+ return `${p.month} ${p.day}, ${p.year}`;
327
+ }
328
+
329
+ /**
330
+ * The scheduled range in plain words. Whole days read as "Oct 1, 2026 to
331
+ * Oct 31, 2026" (the API's `to` is exclusive, so a midnight `to` ends the
332
+ * day before); anything else reads as "from X up to Y".
333
+ */
334
+ export function formatPostRange(range: PostListRange): string {
335
+ const tz = range.timezone;
336
+ if (range.from && range.to && isZonedMidnight(range.from, tz) && isZonedMidnight(range.to, tz)) {
337
+ const lastDay = new Date(Date.parse(range.to) - 1);
338
+ return `${zonedDay(range.from, tz)} to ${zonedDay(lastDay, tz)} (${tz})`;
339
+ }
340
+ const from = range.from ? `from ${formatDateTimeInZone(range.from, tz)}` : '';
341
+ const to = range.to ? `up to ${formatDateTimeInZone(range.to, tz)}` : '';
342
+ return `${[from, to].filter(Boolean).join(' ')} (${tz})`;
343
+ }
344
+
345
+ function approvalCell(post: Post): string {
346
+ if (!post.approval || post.approval.status === 'none') return 'none';
347
+ return post.approval.waiting_on ? `${post.approval.status} (waiting on ${post.approval.waiting_on})` : post.approval.status;
348
+ }
349
+
350
+ /**
351
+ * One page of list_posts. Always says whether every matching post is shown,
352
+ * and when it is not, exactly which offset to call next, so an agent never
353
+ * mistakes the first page for the whole schedule.
354
+ */
355
+ export function formatPostListPage(
356
+ data: PostListPage,
357
+ options: { timeZone: string; dashboardUrl?: string },
358
+ ): string {
359
+ const posts = Array.isArray(data.posts) ? data.posts : [];
360
+ const total = typeof data.total === 'number' ? data.total : posts.length;
361
+ const offset = typeof data.offset === 'number' ? data.offset : 0;
362
+ const tz = options.timeZone;
363
+ const rangeLine = data.range ? `Scheduled ${formatPostRange(data.range)}` : '';
364
+ const orderLine = data.order === 'asc' ? 'oldest first' : 'newest first';
365
+
366
+ if (posts.length === 0) {
367
+ const detail = total > 0
368
+ ? `No posts on this page: offset ${offset} is past the last of the ${total} matching posts. Call again with a lower offset.`
369
+ : `No posts match these filters${data.range ? ` (${formatPostRange(data.range)})` : ''}. This is complete: there is nothing more to page through.`;
370
+ return [
371
+ mdTitle('No posts'),
372
+ detail,
373
+ '\n**Next step for the AI agent:** tell the user nothing matches that filter, then ask what they want to schedule next.',
374
+ ].join('\n');
375
+ }
376
+
377
+ const first = offset + 1;
378
+ const last = offset + posts.length;
379
+ const hasMore = typeof data.has_more === 'boolean' ? data.has_more : last < total;
380
+ const nextOffset = typeof data.next_offset === 'number' ? data.next_offset : last;
381
+ const completeness = hasMore
382
+ ? `**Not complete: showing ${first}-${last} of ${total} matching posts.** More available: call list_posts again with the same filters and offset=${nextOffset}. Keep paging until every post is shown before drawing any conclusion about totals, gaps, or how busy a day or client is. For "how many" questions, use view=counts.`
383
+ : first === 1
384
+ ? `**Complete: all ${total} matching posts are shown.**`
385
+ : `**Complete: this is the last page (posts ${first}-${last} of ${total}).**`;
386
+
387
+ const table = mdTable(
388
+ ['Status', 'Post ID', `Scheduled (${tz})`, 'Type', 'Account ID', 'Brand ID', 'Approval', 'Caption'],
389
+ // The API post row carries account and brand IDs only (no joined names);
390
+ // list_accounts and list_brands, or view=counts, resolve them.
391
+ posts.map((post) => [
392
+ statusLabel(post.status),
393
+ code(String(post.id)),
394
+ formatDateTimeInZone(post.scheduled_at, tz),
395
+ post.post_type || 'post',
396
+ post.social_account_id != null ? String(post.social_account_id) : 'n/a',
397
+ post.brand_id || 'none',
398
+ approvalCell(post),
399
+ compactText(post.content, 80) || '(empty)',
400
+ ]),
401
+ );
402
+
403
+ return [
404
+ mdTitle(
405
+ hasMore || first > 1 ? `Posts ${first}-${last} of ${total}` : `Posts (${total})`,
406
+ [rangeLine, orderLine, `times in ${tz}`].filter(Boolean).join(' · '),
407
+ ),
408
+ table,
409
+ completeness,
410
+ options.dashboardUrl ? `View in posterly: ${options.dashboardUrl}` : '',
411
+ ].filter(Boolean).join('\n\n');
412
+ }
413
+
414
+ /** The counts view of list_posts: exact totals per day, status, account, and brand. */
415
+ export function formatPostCounts(data: PostCountsResponse): string {
416
+ const range = data.range;
417
+ const days = Array.isArray(data.by_day) ? data.by_day : [];
418
+ const emptyDays = days.filter((day) => day.count === 0).map((day) => formatCalendarDay(day.date));
419
+ const sections = [
420
+ mdTitle(`Post counts: ${data.total} ${data.total === 1 ? 'post' : 'posts'}`, range ? formatPostRange(range) : undefined),
421
+ data.truncated
422
+ ? `**Partial grouping:** ${data.total} posts match, but only the first ${data.counted} were grouped below. The total is exact; narrow the range or filters for exact per-day numbers.`
423
+ : `**Complete:** every one of the ${data.total} matching posts is counted below.`,
424
+ mdSection('By day', mdTable(['Day', 'Posts'], days.map((day) => [formatCalendarDay(day.date), day.count]))),
425
+ // A truncated grouping only covers the earliest posts, so a day with a
426
+ // zero here may still have posts: never list "empty" days then.
427
+ days.length > 0 && !data.truncated ? `Days with no posts: ${emptyDays.length > 0 ? emptyDays.join('; ') : 'none'}.` : '',
428
+ data.truncated ? '_Per-day numbers above cover only the grouped posts, so a day showing 0 may still have posts. Narrow the range to check for empty days._' : '',
429
+ mdSection('By status', mdTable(['Status', 'Posts'], (data.by_status || []).map((row) => [statusLabel(row.status), row.count]))),
430
+ mdSection('By account', mdTable(
431
+ ['Account', 'Platform', 'Account ID', 'Posts'],
432
+ (data.by_account || []).map((row) => [
433
+ row.username ? `@${row.username}` : row.account_id ? 'unknown account' : 'no account',
434
+ row.platform || 'n/a',
435
+ row.account_id ?? 'none',
436
+ row.count,
437
+ ]),
438
+ )),
439
+ mdSection('By brand', mdTable(
440
+ ['Brand', 'Brand ID', 'Posts'],
441
+ (data.by_brand || []).map((row) => [row.name || (row.brand_id ? 'unknown brand' : 'no brand'), row.brand_id ?? 'none', row.count]),
442
+ )),
443
+ '**Next step for the AI agent:** these totals are exact for the filters above. To read the posts themselves, call list_posts with view=list and the same filters, paging with offset until every post is shown.',
444
+ ];
445
+ return sections.filter(Boolean).join('\n\n');
446
+ }
@@ -5,4 +5,4 @@
5
5
  // tool set (minus the intentional pre-auth signup tools that only this stdio
6
6
  // package exposes). `npm run check:mcp-parity` enforces both the version match
7
7
  // and the tool-list match, and runs in the pre-commit hook.
8
- export const POSTERLY_MCP_VERSION = '0.48.5';
8
+ export const POSTERLY_MCP_VERSION = '0.49.0';
@@ -1,12 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ import { formatPagingNote } from '../lib/format.js';
3
4
 
4
5
  export const getUpdatesTool = {
5
6
  name: 'get_updates',
6
7
  description:
7
- 'Get the latest posterly product updates and news from the updates feed: new features, improvements, and fixes. Requires an active posterly subscription.',
8
+ 'Get the latest posterly product updates and news from the updates feed: new features, improvements, and fixes, newest first. The output says whether more updates exist; page with offset or narrow with since. Requires an active posterly subscription.',
8
9
  inputSchema: z.object({
9
10
  limit: z.number().int().min(1).max(50).optional().describe('Number of updates to return. Default 10, max 50.'),
11
+ offset: z.number().int().min(0).optional().describe('How many updates to skip. Use the offset the previous page gives to get the next page.'),
10
12
  since: z.string().optional().describe('ISO date. Return updates published on or after this date.'),
11
13
  include_content: z.boolean().optional().describe('Include the full markdown body of each update. Defaults to false.'),
12
14
  }),
@@ -15,6 +17,7 @@ export const getUpdatesTool = {
15
17
  client: PosterlyClient,
16
18
  input: {
17
19
  limit?: number;
20
+ offset?: number;
18
21
  since?: string;
19
22
  include_content?: boolean;
20
23
  },
@@ -36,6 +39,17 @@ export const getUpdatesTool = {
36
39
  }
37
40
  }
38
41
 
42
+ lines.push('', formatPagingNote({
43
+ noun: 'updates',
44
+ tool: 'get_updates',
45
+ offset: result.offset ?? input.offset ?? 0,
46
+ count: result.updates.length,
47
+ total: result.total,
48
+ // An API older than has_more: a full page may mean more exist.
49
+ hasMore: result.has_more ?? result.updates.length >= (input.limit || 10),
50
+ nextOffset: result.next_offset,
51
+ }));
52
+
39
53
  return lines.join('\n');
40
54
  },
41
55
  };
@@ -1,16 +1,18 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ import { formatPagingNote } from '../lib/format.js';
3
4
 
4
5
  export const listActivityTool = {
5
6
  name: 'list_activity',
6
7
  description:
7
- 'List recent activity and publish events for posts, including status changes, publish attempts, failures, and retries. Use this as the agent notifications feed.',
8
+ 'List recent activity and publish events for posts, including status changes, publish attempts, failures, and retries, newest first. Use this as the agent notifications feed. The output says whether more events exist; page with offset (offset + limit up to 1000) or narrow with since or post_id before concluding nothing else happened.',
8
9
  inputSchema: z.object({
9
10
  workspace_id: z.string().optional().describe('Filter to a workspace ID from whoami.'),
10
11
  post_id: z.number().int().positive().optional().describe('Filter to one post ID.'),
11
12
  since: z.string().optional().describe('ISO datetime. Return events created at or after this time.'),
12
13
  type: z.enum(['all', 'activity', 'publish']).optional().describe('Event source filter. Defaults to all.'),
13
14
  limit: z.number().int().min(1).max(100).optional().describe('Number of items to return. Default 50, max 100.'),
15
+ offset: z.number().int().min(0).optional().describe('How many events to skip. Use the offset the previous page gives to get the next page.'),
14
16
  }),
15
17
 
16
18
  async execute(
@@ -21,15 +23,18 @@ export const listActivityTool = {
21
23
  since?: string;
22
24
  type?: 'all' | 'activity' | 'publish';
23
25
  limit?: number;
26
+ offset?: number;
24
27
  },
25
28
  ) {
26
29
  const result = await client.listActivity(input);
27
30
  if (result.items.length === 0) {
28
- return 'No activity found matching your filters.';
31
+ return (input.offset ?? 0) > 0
32
+ ? `No activity on this page: offset ${input.offset} is past the last matching event.`
33
+ : 'No activity found matching your filters.';
29
34
  }
30
35
 
31
36
  const lines = [
32
- `Activity (${result.items.length} of ${result.total}):`,
37
+ `Activity (${result.items.length} on this page):`,
33
38
  ];
34
39
 
35
40
  for (const item of result.items) {
@@ -45,6 +50,16 @@ export const listActivityTool = {
45
50
  }
46
51
  }
47
52
 
53
+ lines.push('', formatPagingNote({
54
+ noun: 'events',
55
+ tool: 'list_activity',
56
+ offset: result.offset ?? input.offset ?? 0,
57
+ count: result.items.length,
58
+ // An API older than has_more: a full page may mean more exist.
59
+ hasMore: result.has_more ?? result.items.length >= (result.limit || 50),
60
+ nextOffset: result.next_offset,
61
+ }));
62
+
48
63
  return lines.join('\n');
49
64
  },
50
65
  };
@@ -1,10 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
+ import { formatPagingNote } from '../lib/format.js';
3
4
 
4
5
  export const listMediaTool = {
5
6
  name: 'list_media',
6
7
  description:
7
- 'List the newest media assets for the authenticated user. After create_media_drop, pass drop_session_id to see files the human uploaded on /drop/<token>. Use public_url with validate_post / create_post.',
8
+ 'List the newest media assets for the authenticated user. After create_media_drop, pass drop_session_id to see files the human uploaded on /drop/<token>. Use public_url with validate_post / create_post. The output says whether every matching file is shown; page with offset (offset + limit up to 1000) when it is not.',
8
9
  inputSchema: z.object({
9
10
  limit: z
10
11
  .number()
@@ -13,6 +14,12 @@ export const listMediaTool = {
13
14
  .max(50)
14
15
  .optional()
15
16
  .describe('Number of assets to return (default 10, max 50)'),
17
+ offset: z
18
+ .number()
19
+ .int()
20
+ .min(0)
21
+ .optional()
22
+ .describe('How many assets to skip. Use the offset the previous page gives to get the next page.'),
16
23
  drop_session_id: z
17
24
  .string()
18
25
  .optional()
@@ -21,14 +28,18 @@ export const listMediaTool = {
21
28
 
22
29
  async execute(
23
30
  client: PosterlyClient,
24
- input: { limit?: number; drop_session_id?: string },
31
+ input: { limit?: number; offset?: number; drop_session_id?: string },
25
32
  ) {
26
33
  const result = await client.listMedia({
27
34
  limit: input.limit,
35
+ offset: input.offset,
28
36
  dropSessionId: input.drop_session_id,
29
37
  });
30
38
 
31
39
  if (result.media.length === 0) {
40
+ if ((input.offset ?? 0) > 0 && result.total > 0) {
41
+ return `No media on this page: offset ${input.offset} is past the last of the ${result.total} matching files.`;
42
+ }
32
43
  return 'No media found. If you just sent a drop URL, wait for the user to finish uploading, then call list_media again.';
33
44
  }
34
45
 
@@ -38,10 +49,20 @@ export const listMediaTool = {
38
49
  return `• ${item.filename} (${item.mime || 'unknown'}, ${size})${drop}\n public_url: ${item.public_url}`;
39
50
  });
40
51
 
52
+ const offset = result.offset ?? input.offset ?? 0;
41
53
  return [
42
54
  `Media (${result.media.length} of ${result.total}):`,
43
55
  ...lines,
44
56
  '',
57
+ formatPagingNote({
58
+ noun: 'files',
59
+ tool: 'list_media',
60
+ offset,
61
+ count: result.media.length,
62
+ total: result.total,
63
+ hasMore: result.has_more ?? offset + result.media.length < result.total,
64
+ nextOffset: result.next_offset,
65
+ }),
45
66
  'Pass public_url to validate_post / create_post. Do not invent URLs.',
46
67
  ].join('\n');
47
68
  },
@@ -1,11 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import type { PosterlyClient } from '../lib/api-client.js';
3
- import { mdTitle, mdSection, mdKeyValue, mdEmpty, truncateText } from '../lib/format.js';
3
+ import { formatPagingNote, mdTitle, mdSection, mdKeyValue, mdEmpty, truncateText } from '../lib/format.js';
4
4
 
5
5
  export const listPostSuggestionsTool = {
6
6
  name: 'list_post_suggestions',
7
7
  description:
8
- "List proactive post suggestions: evidence-based weekly drafts posterly writes in each account's learned voice, each with a rationale tying it back to what has performed well for that account. Filter by account_id and status (pending, scheduled, dismissed; default pending). Read-only; requires a Pro plan or higher. Use dismiss_suggestion to hide one.",
8
+ "List proactive post suggestions: evidence-based weekly drafts posterly writes in each account's learned voice, each with a rationale tying it back to what has performed well for that account. Filter by account_id and status (pending, scheduled, dismissed; default pending). The output says whether every matching suggestion is shown; page with offset until it does. Read-only; requires a Pro plan or higher. Use dismiss_suggestion to hide one.",
9
9
  inputSchema: z.object({
10
10
  account_id: z
11
11
  .union([z.string(), z.number()])
@@ -16,6 +16,7 @@ export const listPostSuggestionsTool = {
16
16
  .optional()
17
17
  .describe('Suggestion status to list (default pending).'),
18
18
  limit: z.number().int().min(1).max(100).optional().describe('Max suggestions to return (default 20).'),
19
+ offset: z.number().int().min(0).optional().describe('How many suggestions to skip. Use the offset the previous page gives to get the next page.'),
19
20
  }),
20
21
 
21
22
  async execute(
@@ -24,9 +25,11 @@ export const listPostSuggestionsTool = {
24
25
  account_id?: string | number;
25
26
  status?: 'pending' | 'scheduled' | 'dismissed';
26
27
  limit?: number;
28
+ offset?: number;
27
29
  },
28
30
  ) {
29
- const { suggestions } = await client.listPostSuggestions(input);
31
+ const page = await client.listPostSuggestions(input);
32
+ const { suggestions } = page;
30
33
 
31
34
  if (!suggestions.length) {
32
35
  return mdEmpty(
@@ -51,6 +54,16 @@ export const listPostSuggestionsTool = {
51
54
  );
52
55
  });
53
56
 
54
- return [mdTitle(`Post suggestions (${suggestions.length})`), ...blocks].join('\n\n');
57
+ const note = formatPagingNote({
58
+ noun: 'suggestions',
59
+ tool: 'list_post_suggestions',
60
+ offset: page.offset ?? input.offset ?? 0,
61
+ count: suggestions.length,
62
+ total: page.total,
63
+ // An API older than has_more: a full page may mean more exist.
64
+ hasMore: page.has_more ?? suggestions.length >= (input.limit || 20),
65
+ nextOffset: page.next_offset,
66
+ });
67
+ return [mdTitle(`Post suggestions (${suggestions.length})`), ...blocks, note].join('\n\n');
55
68
  },
56
69
  };
@@ -1,13 +1,31 @@
1
1
  import { z } from 'zod';
2
- import type { PosterlyClient } from '../lib/api-client.js';
3
- import { dashboardUrlForPostList, formatLocalDateTime, truncateText } from '../lib/format.js';
2
+ import type { ListPostsParams, PosterlyClient } from '../lib/api-client.js';
3
+ import { dashboardUrlForPostList, formatPostCounts, formatPostListPage } from '../lib/format.js';
4
+ import { validateArgs, type ToolContext } from '../lib/tool-runtime.js';
4
5
  import { SUPPORTED_PLATFORM_INPUTS, SUPPORTED_PLATFORM_IDS } from '../generated/platform-manifest.js';
5
6
 
7
+ type ListPostsInput = ListPostsParams & {
8
+ view?: 'list' | 'counts';
9
+ limit?: number;
10
+ offset?: number;
11
+ order?: 'asc' | 'desc';
12
+ };
13
+
14
+ export const LIST_POSTS_DESCRIPTION =
15
+ 'Posts in your workspaces, teammates\' too, per your role there (brand-locked seats only see their assigned brands). If workspace_id is omitted, this searches every workspace you belong to. ' +
16
+ 'view=list (default): posts filtered by status (scheduled, published, failed, draft), platform, account_id, workspace_id, brand_id, approval_status, approval_waiting_on, and a scheduled date range: scheduled_from (inclusive) and scheduled_to (a plain date includes that whole day), read in timezone (IANA, default UTC; also the zone times are shown in). Returns limit posts (default 20, max 200) starting at offset; with a date range the order defaults to oldest first (order=asc|desc). ' +
17
+ 'The output always says whether every matching post was shown. If it says more are available, call again with the offset it gives and keep paging until all are shown before drawing any conclusion about totals, gaps, or how busy a client is. ' +
18
+ 'view=counts (scheduled_from and scheduled_to required, up to 366 days): exact totals per day, status, account, and brand for the same filters (limit, offset, and order are ignored); use it for "how many" or "is this month full" questions. ' +
19
+ 'approval_waiting_on=client is the same queue as approval_status=pending_client. Each post includes approval.pending_client_approval.';
20
+
6
21
  export const listPostsTool = {
7
22
  name: 'list_posts',
8
- description:
9
- 'List upcoming or recent posts. Filter by status (scheduled, published, failed, draft), platform, account_id, workspace_id, approval_status, approval_waiting_on, or brand_id. approval_waiting_on=client is the same queue as approval_status=pending_client. Each post includes approval.pending_client_approval. Returns posts in your workspaces, teammates\' too, per your role there (brand-locked seats only see their assigned brands). If workspace_id is omitted, this searches every workspace you belong to.',
23
+ description: LIST_POSTS_DESCRIPTION,
10
24
  inputSchema: z.object({
25
+ view: z
26
+ .enum(['list', 'counts'])
27
+ .optional()
28
+ .describe('list (default): the posts themselves, one page at a time. counts: exact totals per day, status, account, and brand (needs scheduled_from and scheduled_to).'),
11
29
  status: z
12
30
  .string()
13
31
  .optional()
@@ -20,12 +38,34 @@ export const listPostsTool = {
20
38
  .string()
21
39
  .optional()
22
40
  .describe('Filter to a specific social account ID (from list_accounts).'),
41
+ scheduled_from: z
42
+ .string()
43
+ .optional()
44
+ .describe('Start of the scheduled range, inclusive: a date (2026-10-01) or an ISO date-time. Plain dates and times without an offset are read in timezone.'),
45
+ scheduled_to: z
46
+ .string()
47
+ .optional()
48
+ .describe('End of the scheduled range: a plain date (2026-10-31) includes that whole day; an ISO date-time is exclusive.'),
49
+ timezone: z
50
+ .string()
51
+ .optional()
52
+ .describe('IANA time zone such as Europe/London for reading plain dates and showing times. Default UTC.'),
53
+ order: z
54
+ .enum(['asc', 'desc'])
55
+ .optional()
56
+ .describe('asc (oldest first) or desc (newest first). Default asc with a date range, otherwise desc. Ignored by view=counts.'),
23
57
  limit: z
24
58
  .number()
25
59
  .min(1)
26
- .max(100)
60
+ .max(200)
61
+ .optional()
62
+ .describe('Posts per page (default 20, max 200). Ignored by view=counts.'),
63
+ offset: z
64
+ .number()
65
+ .int()
66
+ .min(0)
27
67
  .optional()
28
- .describe('Number of posts to return (default 20, max 100)'),
68
+ .describe('How many matching posts to skip. Use the offset the previous page gives to get the next page. Ignored by view=counts.'),
29
69
  workspace_id: z
30
70
  .string()
31
71
  .optional()
@@ -44,48 +84,37 @@ export const listPostsTool = {
44
84
  .describe('Filter to posts assigned to a specific brand/client (the id from list_brands).'),
45
85
  }),
46
86
 
47
- async execute(
48
- client: PosterlyClient,
49
- input: {
50
- status?: string;
51
- platform?: string;
52
- account_id?: string;
53
- limit?: number;
54
- workspace_id?: string;
55
- approval_status?: string;
56
- approval_waiting_on?: 'client' | 'team';
57
- brand_id?: string;
58
- }
59
- ) {
60
- const { posts, total } = await client.listPosts({
61
- ...input,
62
- limit: input.limit || 20,
63
- });
87
+ async execute(client: PosterlyClient, input: ListPostsInput, ctx: ToolContext) {
88
+ const { mode, args } = validateArgs(ctx, 'list_posts', input);
89
+ const filters = postFilters(args as ListPostsInput);
90
+ const timeZone = filters.timezone || 'UTC';
64
91
 
65
- if (posts.length === 0) {
66
- return 'No posts found matching your criteria.\n\nNext step for the AI agent: tell the user their posterly queue is clear for that filter, then ask what they want to schedule next.';
92
+ if (mode === 'counts') {
93
+ return formatPostCounts(await client.countPosts(filters));
67
94
  }
68
95
 
69
- const lines = posts.map((p) => {
70
- const date = formatLocalDateTime(p.scheduled_at);
71
- const caption = truncateText(p.content, 60);
72
- const approvalBits: string[] = [];
73
- if (p.approval && p.approval.status !== 'none') {
74
- approvalBits.push(`approval: ${p.approval.status}`);
75
- if (p.approval.waiting_on) approvalBits.push(`waiting on: ${p.approval.waiting_on}`);
76
- }
77
- if (p.brand_id) approvalBits.push(`brand: ${p.brand_id}`);
78
- if (p.content_plan_id) approvalBits.push(`plan: ${p.content_plan_id}`);
79
- const approvalSuffix = approvalBits.length > 0 ? ` [${approvalBits.join(', ')}]` : '';
80
- return `• [${p.status}] #${p.id}: ${caption} (${date})${approvalSuffix}`;
96
+ const hasRange = Boolean(filters.scheduled_from || filters.scheduled_to);
97
+ const page = await client.listPosts({
98
+ ...filters,
99
+ limit: (args as ListPostsInput).limit || 20,
100
+ offset: (args as ListPostsInput).offset,
101
+ order: (args as ListPostsInput).order ?? (hasRange ? 'asc' : undefined),
81
102
  });
82
-
83
- return [
84
- `Posts (${posts.length} of ${total}):`,
85
- ...lines,
86
- '',
87
- `View in posterly: ${dashboardUrlForPostList(posts)}`,
88
- 'Next step for the AI agent: summarize the queue in plain language and use the dashboard link above if the user wants to inspect it in posterly.',
89
- ].join('\n');
103
+ return formatPostListPage(page, { timeZone, dashboardUrl: dashboardUrlForPostList(page.posts || []) });
90
104
  },
91
105
  };
106
+
107
+ function postFilters(input: ListPostsInput): ListPostsParams {
108
+ return {
109
+ status: input.status,
110
+ platform: input.platform,
111
+ account_id: input.account_id,
112
+ workspace_id: input.workspace_id,
113
+ approval_status: input.approval_status,
114
+ approval_waiting_on: input.approval_waiting_on,
115
+ brand_id: input.brand_id,
116
+ scheduled_from: input.scheduled_from,
117
+ scheduled_to: input.scheduled_to,
118
+ timezone: input.timezone,
119
+ };
120
+ }