posterly-mcp-server 0.48.4 → 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 (58) hide show
  1. package/README.md +18 -3
  2. package/dist/index.js +4 -0
  3. package/dist/lib/api-client.d.ts +149 -12
  4. package/dist/lib/api-client.js +59 -16
  5. package/dist/lib/format.d.ts +39 -2
  6. package/dist/lib/format.js +158 -0
  7. package/dist/lib/version.d.ts +1 -1
  8. package/dist/lib/version.js +1 -1
  9. package/dist/tools/audit-google-business-profile.d.ts +2 -2
  10. package/dist/tools/connect-account.d.ts +2 -2
  11. package/dist/tools/create-post.d.ts +12 -12
  12. package/dist/tools/create-posts-batch.d.ts +8 -8
  13. package/dist/tools/create-webhook.d.ts +2 -2
  14. package/dist/tools/disconnect-account.d.ts +2 -2
  15. package/dist/tools/find-slot.d.ts +2 -2
  16. package/dist/tools/get-updates.d.ts +4 -0
  17. package/dist/tools/get-updates.js +13 -1
  18. package/dist/tools/list-accounts.d.ts +2 -2
  19. package/dist/tools/list-activity.d.ts +4 -0
  20. package/dist/tools/list-activity.js +16 -3
  21. package/dist/tools/list-analytics.d.ts +2 -2
  22. package/dist/tools/list-brands.d.ts +2 -2
  23. package/dist/tools/list-comments.d.ts +2 -2
  24. package/dist/tools/list-conversations.d.ts +2 -2
  25. package/dist/tools/list-google-business-media.d.ts +2 -2
  26. package/dist/tools/list-google-business-reviews.d.ts +4 -4
  27. package/dist/tools/list-media.d.ts +4 -0
  28. package/dist/tools/list-media.js +22 -1
  29. package/dist/tools/list-platforms.d.ts +4 -4
  30. package/dist/tools/list-post-suggestions.d.ts +4 -0
  31. package/dist/tools/list-post-suggestions.js +16 -4
  32. package/dist/tools/list-posts.d.ts +35 -17
  33. package/dist/tools/list-posts.js +63 -33
  34. package/dist/tools/list-publishing-pauses.d.ts +19 -0
  35. package/dist/tools/list-publishing-pauses.js +81 -0
  36. package/dist/tools/manage-comment.d.ts +2 -2
  37. package/dist/tools/manage-conversation.d.ts +4 -4
  38. package/dist/tools/manage-google-business-media.d.ts +8 -8
  39. package/dist/tools/manage-google-business-review.d.ts +10 -10
  40. package/dist/tools/manage-publishing-pause.d.ts +35 -0
  41. package/dist/tools/manage-publishing-pause.js +47 -0
  42. package/dist/tools/manage-webhook.d.ts +4 -4
  43. package/dist/tools/validate-post.d.ts +8 -8
  44. package/package.json +1 -1
  45. package/server.json +2 -2
  46. package/src/index.ts +4 -0
  47. package/src/lib/api-client.ts +172 -17
  48. package/src/lib/format.ts +201 -1
  49. package/src/lib/version.ts +1 -1
  50. package/src/tools/get-updates.ts +15 -1
  51. package/src/tools/list-activity.ts +18 -3
  52. package/src/tools/list-media.ts +23 -2
  53. package/src/tools/list-post-suggestions.ts +17 -4
  54. package/src/tools/list-posts.ts +74 -45
  55. package/src/tools/list-publishing-pauses.ts +98 -0
  56. package/src/tools/manage-publishing-pause.ts +62 -0
  57. package/tool-annotations.json +5 -0
  58. package/tool-registry.json +173 -6
@@ -28,20 +28,20 @@ export declare const manageWebhookTool: {
28
28
  }, "strip", z.ZodTypeAny, {
29
29
  action: "update" | "delete" | "test";
30
30
  webhook_id: string;
31
+ workspace_id?: string | null | undefined;
31
32
  description?: string | null | undefined;
32
33
  confirm?: boolean | undefined;
33
34
  preview_id?: string | undefined;
34
- workspace_id?: string | null | undefined;
35
35
  url?: string | undefined;
36
36
  is_active?: boolean | undefined;
37
37
  events?: ("webhook.test" | "post.created" | "post.updated" | "post.deleted" | "post.publishing" | "post.published" | "post.failed" | "account.disconnected" | "analytics.synced" | "approval.requested" | "approval.approved" | "approval.changes_requested" | "approval.rejected" | "approval.commented")[] | undefined;
38
38
  }, {
39
39
  action: "update" | "delete" | "test";
40
40
  webhook_id: string;
41
+ workspace_id?: string | null | undefined;
41
42
  description?: string | null | undefined;
42
43
  confirm?: boolean | undefined;
43
44
  preview_id?: string | undefined;
44
- workspace_id?: string | null | undefined;
45
45
  url?: string | undefined;
46
46
  is_active?: boolean | undefined;
47
47
  events?: ("webhook.test" | "post.created" | "post.updated" | "post.deleted" | "post.publishing" | "post.published" | "post.failed" | "account.disconnected" | "analytics.synced" | "approval.requested" | "approval.approved" | "approval.changes_requested" | "approval.rejected" | "approval.commented")[] | undefined;
@@ -59,16 +59,16 @@ export declare const manageWebhookTool: {
59
59
  }, "strip", z.ZodTypeAny, {
60
60
  confirm: true;
61
61
  webhook_id: string;
62
- description?: string | null | undefined;
63
62
  workspace_id?: string | null | undefined;
63
+ description?: string | null | undefined;
64
64
  url?: string | undefined;
65
65
  is_active?: boolean | undefined;
66
66
  events?: ("webhook.test" | "post.created" | "post.updated" | "post.deleted" | "post.publishing" | "post.published" | "post.failed" | "account.disconnected" | "analytics.synced" | "approval.requested" | "approval.approved" | "approval.changes_requested" | "approval.rejected" | "approval.commented")[] | undefined;
67
67
  }, {
68
68
  confirm: true;
69
69
  webhook_id: string;
70
- description?: string | null | undefined;
71
70
  workspace_id?: string | null | undefined;
71
+ description?: string | null | undefined;
72
72
  url?: string | undefined;
73
73
  is_active?: boolean | undefined;
74
74
  events?: ("webhook.test" | "post.created" | "post.updated" | "post.deleted" | "post.publishing" | "post.published" | "post.failed" | "account.disconnected" | "analytics.synced" | "approval.requested" | "approval.approved" | "approval.changes_requested" | "approval.rejected" | "approval.commented")[] | undefined;
@@ -653,9 +653,9 @@ export declare const validatePostInputSchema: import("zod").ZodObject<{
653
653
  workspace_id: import("zod").ZodOptional<import("zod").ZodString>;
654
654
  }, "strip", import("zod").ZodTypeAny, {
655
655
  platform?: "instagram" | "instagram-standalone" | "facebook" | "tiktok" | "twitter" | "threads" | "linkedin" | "youtube" | "pinterest" | "google_business" | "x" | "telegram" | "bluesky" | "discord" | "wordpress" | "devto" | "hashnode" | "lemmy" | "slack" | "mastodon" | "gmb" | "google-business" | "google_business_profile" | undefined;
656
- username?: string | undefined;
657
- workspace_id?: string | undefined;
658
656
  account_id?: string | undefined;
657
+ workspace_id?: string | undefined;
658
+ username?: string | undefined;
659
659
  caption?: string | undefined;
660
660
  scheduled_at?: string | undefined;
661
661
  media_url?: string | undefined;
@@ -854,9 +854,9 @@ export declare const validatePostInputSchema: import("zod").ZodObject<{
854
854
  }, import("zod").ZodTypeAny, "passthrough"> | undefined;
855
855
  }, {
856
856
  platform?: "instagram" | "instagram-standalone" | "facebook" | "tiktok" | "twitter" | "threads" | "linkedin" | "youtube" | "pinterest" | "google_business" | "x" | "telegram" | "bluesky" | "discord" | "wordpress" | "devto" | "hashnode" | "lemmy" | "slack" | "mastodon" | "gmb" | "google-business" | "google_business_profile" | undefined;
857
- username?: string | undefined;
858
- workspace_id?: string | undefined;
859
857
  account_id?: string | undefined;
858
+ workspace_id?: string | undefined;
859
+ username?: string | undefined;
860
860
  caption?: string | undefined;
861
861
  scheduled_at?: string | undefined;
862
862
  media_url?: string | undefined;
@@ -1711,9 +1711,9 @@ export declare const validatePostTool: {
1711
1711
  workspace_id: import("zod").ZodOptional<import("zod").ZodString>;
1712
1712
  }, "strip", import("zod").ZodTypeAny, {
1713
1713
  platform?: "instagram" | "instagram-standalone" | "facebook" | "tiktok" | "twitter" | "threads" | "linkedin" | "youtube" | "pinterest" | "google_business" | "x" | "telegram" | "bluesky" | "discord" | "wordpress" | "devto" | "hashnode" | "lemmy" | "slack" | "mastodon" | "gmb" | "google-business" | "google_business_profile" | undefined;
1714
- username?: string | undefined;
1715
- workspace_id?: string | undefined;
1716
1714
  account_id?: string | undefined;
1715
+ workspace_id?: string | undefined;
1716
+ username?: string | undefined;
1717
1717
  caption?: string | undefined;
1718
1718
  scheduled_at?: string | undefined;
1719
1719
  media_url?: string | undefined;
@@ -1912,9 +1912,9 @@ export declare const validatePostTool: {
1912
1912
  }, import("zod").ZodTypeAny, "passthrough"> | undefined;
1913
1913
  }, {
1914
1914
  platform?: "instagram" | "instagram-standalone" | "facebook" | "tiktok" | "twitter" | "threads" | "linkedin" | "youtube" | "pinterest" | "google_business" | "x" | "telegram" | "bluesky" | "discord" | "wordpress" | "devto" | "hashnode" | "lemmy" | "slack" | "mastodon" | "gmb" | "google-business" | "google_business_profile" | undefined;
1915
- username?: string | undefined;
1916
- workspace_id?: string | undefined;
1917
1915
  account_id?: string | undefined;
1916
+ workspace_id?: string | undefined;
1917
+ username?: string | undefined;
1918
1918
  caption?: string | undefined;
1919
1919
  scheduled_at?: string | undefined;
1920
1920
  media_url?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.48.4",
3
+ "version": "0.49.0",
4
4
  "mcpName": "io.github.awpthorp/posterly",
5
5
  "description": "MCP server for posterly: schedule and publish social media posts across 18 platforms from any MCP client (Claude, ChatGPT, Cursor, Windsurf, Cline, and more)",
6
6
  "license": "MIT",
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.awpthorp/posterly",
4
4
  "title": "posterly",
5
5
  "description": "Validate, schedule, publish, and analyze social content across 18 platforms with posterly.",
6
- "version": "0.48.4",
6
+ "version": "0.49.0",
7
7
  "websiteUrl": "https://www.poster.ly/mcp",
8
8
  "repository": {
9
9
  "url": "https://github.com/awpthorp/posterly",
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "posterly-mcp-server",
17
- "version": "0.48.4",
17
+ "version": "0.49.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
package/src/index.ts CHANGED
@@ -24,6 +24,8 @@ import { createApiKeyTool } from './tools/create-api-key.js';
24
24
  import { deleteApiKeyTool } from './tools/delete-api-key.js';
25
25
  import { listWorkspaceMembersTool } from './tools/list-workspace-members.js';
26
26
  import { manageWorkspaceMemberTool } from './tools/manage-workspace-member.js';
27
+ import { listPublishingPausesTool } from './tools/list-publishing-pauses.js';
28
+ import { managePublishingPauseTool } from './tools/manage-publishing-pause.js';
27
29
  import { getCreditsTool } from './tools/get-credits.js';
28
30
  import { getSubscriptionTool } from './tools/get-subscription.js';
29
31
  import { cancelSubscriptionTool } from './tools/cancel-subscription.js';
@@ -108,6 +110,8 @@ const STDIO_TOOLS: ToolModule[] = [
108
110
  deleteApiKeyTool,
109
111
  listWorkspaceMembersTool,
110
112
  manageWorkspaceMemberTool,
113
+ listPublishingPausesTool,
114
+ managePublishingPauseTool,
111
115
  getCreditsTool,
112
116
  getSubscriptionTool,
113
117
  cancelSubscriptionTool,
@@ -176,6 +176,11 @@ export interface PostSuggestion {
176
176
 
177
177
  export interface PostSuggestionsResponse {
178
178
  suggestions: PostSuggestion[];
179
+ total?: number;
180
+ limit?: number;
181
+ offset?: number;
182
+ has_more?: boolean;
183
+ next_offset?: number | null;
179
184
  }
180
185
 
181
186
  export interface DismissSuggestionResponse {
@@ -216,6 +221,63 @@ export interface Post {
216
221
  approval?: ApiPostApproval;
217
222
  }
218
223
 
224
+ /** Filters shared by GET /posts and GET /posts/counts. */
225
+ export interface ListPostsParams {
226
+ status?: string;
227
+ platform?: string;
228
+ account_id?: string;
229
+ workspace_id?: string;
230
+ approval_status?: string;
231
+ approval_waiting_on?: string;
232
+ brand_id?: string;
233
+ scheduled_from?: string;
234
+ scheduled_to?: string;
235
+ timezone?: string;
236
+ }
237
+
238
+ export interface PostsRangeEcho {
239
+ from: string | null;
240
+ to: string | null;
241
+ timezone: string;
242
+ }
243
+
244
+ export interface ListPostsResponse {
245
+ posts: Post[];
246
+ total: number;
247
+ limit?: number;
248
+ offset?: number;
249
+ has_more?: boolean;
250
+ next_offset?: number | null;
251
+ order?: 'asc' | 'desc';
252
+ range?: PostsRangeEcho | null;
253
+ }
254
+
255
+ export interface PostCountsResponse {
256
+ total: number;
257
+ counted: number;
258
+ truncated: boolean;
259
+ range: PostsRangeEcho | null;
260
+ by_day: Array<{ date: string; count: number }>;
261
+ by_status: Array<{ status: string; count: number }>;
262
+ by_account: Array<{ account_id: string | null; platform: string | null; username: string | null; count: number }>;
263
+ by_brand: Array<{ brand_id: string | null; name: string | null; count: number }>;
264
+ }
265
+
266
+ function postFilterParams(params?: ListPostsParams): URLSearchParams {
267
+ const searchParams = new URLSearchParams();
268
+ if (params?.status) searchParams.set('status', params.status);
269
+ if (params?.platform) searchParams.set('platform', params.platform);
270
+ if (params?.account_id) searchParams.set('account_id', params.account_id);
271
+ if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
272
+ if (params?.approval_status) searchParams.set('approval_status', params.approval_status);
273
+ if (params?.approval_waiting_on) searchParams.set('approval_waiting_on', params.approval_waiting_on);
274
+ if (params?.brand_id) searchParams.set('brand_id', params.brand_id);
275
+ if (params?.scheduled_from) searchParams.set('scheduled_from', params.scheduled_from);
276
+ if (params?.scheduled_to) searchParams.set('scheduled_to', params.scheduled_to);
277
+ if (params?.timezone) searchParams.set('timezone', params.timezone);
278
+ return searchParams;
279
+ }
280
+
219
281
  export interface InstagramPostSettings {
220
282
  __type?: 'instagram' | 'instagram-standalone';
221
283
  post_type?: 'post' | 'feed' | 'story' | 'reel' | 'carousel';
@@ -469,6 +531,54 @@ export type WorkspaceMemberConfirmFields = {
469
531
  preview_id?: string;
470
532
  };
471
533
 
534
+ export type PublishingPauseState = {
535
+ workspace: { id: string; name: string | null; paused: boolean; paused_at: string | null };
536
+ brands: Array<{ id: string; name: string | null; paused: boolean; paused_at: string }>;
537
+ viewer: {
538
+ role: string;
539
+ can_pause_workspace: boolean;
540
+ can_pause_brand: boolean;
541
+ can_resume: boolean;
542
+ can_publish_missed: boolean;
543
+ };
544
+ };
545
+
546
+ /**
547
+ * POST /publishing-pause. A pause without confirm: true changes nothing and
548
+ * answers 400 confirmation_required with a preview; the client returns that
549
+ * body (requires_confirmation: true) instead of throwing, so the tool can show
550
+ * it.
551
+ */
552
+ export type PublishingPauseChangeResponse = {
553
+ ok?: boolean;
554
+ requires_confirmation?: boolean;
555
+ code?: string;
556
+ error?: string;
557
+ action?: 'pause' | 'resume';
558
+ scope?: 'workspace' | 'brand';
559
+ workspace_id?: string;
560
+ workspace_name?: string | null;
561
+ brand_id?: string | null;
562
+ brand_name?: string | null;
563
+ already_paused?: boolean;
564
+ scheduled_posts_to_hold?: number | null;
565
+ held_count?: number;
566
+ resumed_count?: number;
567
+ missed_count?: number;
568
+ missed_post_ids?: number[];
569
+ still_held_count?: number;
570
+ disconnected_count?: number;
571
+ failed_post_ids?: number[];
572
+ message?: string;
573
+ };
574
+
575
+ export type PublishMissedResponse = {
576
+ ok: boolean;
577
+ started: number;
578
+ skipped: number;
579
+ message?: string;
580
+ };
581
+
472
582
  export type CreateApiKeyPayload = {
473
583
  name?: string;
474
584
  scopes?: ApiKeyScope[];
@@ -882,8 +992,12 @@ export interface ActivityItem {
882
992
 
883
993
  export interface ActivityResponse {
884
994
  items: ActivityItem[];
995
+ /** Items on this page (kept for older clients); use has_more to tell whether more exist. */
885
996
  total: number;
886
997
  limit: number;
998
+ offset?: number;
999
+ has_more?: boolean;
1000
+ next_offset?: number | null;
887
1001
  }
888
1002
 
889
1003
  export interface UpdateEntry {
@@ -902,6 +1016,11 @@ export interface UpdateEntry {
902
1016
 
903
1017
  export interface UpdatesResponse {
904
1018
  updates: UpdateEntry[];
1019
+ total?: number;
1020
+ limit?: number;
1021
+ offset?: number;
1022
+ has_more?: boolean;
1023
+ next_offset?: number | null;
905
1024
  }
906
1025
 
907
1026
  export interface ApiWebhook {
@@ -1290,6 +1409,7 @@ export class PosterlyClient {
1290
1409
  method: string,
1291
1410
  path: string,
1292
1411
  body?: unknown,
1412
+ options?: { acceptConfirmationPreview?: boolean },
1293
1413
  ): Promise<T> {
1294
1414
  this.requireApiKey();
1295
1415
 
@@ -1322,6 +1442,11 @@ export class PosterlyClient {
1322
1442
 
1323
1443
  if (!res.ok) {
1324
1444
  const err = await res.json().catch(() => ({ error: res.statusText }));
1445
+ // A write that needs confirm: true answers 400 with a preview; callers
1446
+ // that show previews get the body back instead of an error.
1447
+ if (options?.acceptConfirmationPreview && res.status === 400 && err?.requires_confirmation === true) {
1448
+ return err as T;
1449
+ }
1325
1450
  throw new Error(formatApiError(res, err));
1326
1451
  }
1327
1452
 
@@ -1411,6 +1536,30 @@ export class PosterlyClient {
1411
1536
  return this.request('DELETE', `${path}${qs ? `?${qs}` : ''}`);
1412
1537
  }
1413
1538
 
1539
+ async getPublishingPause(params?: { workspace_id?: string }): Promise<PublishingPauseState> {
1540
+ const query = new URLSearchParams();
1541
+ if (params?.workspace_id) query.set('workspace_id', params.workspace_id);
1542
+ const qs = query.toString();
1543
+ return this.request('GET', `/publishing-pause${qs ? `?${qs}` : ''}`);
1544
+ }
1545
+
1546
+ async changePublishingPause(data: {
1547
+ action: 'pause' | 'resume';
1548
+ brand_id?: string;
1549
+ workspace_id?: string;
1550
+ confirm?: boolean;
1551
+ }): Promise<PublishingPauseChangeResponse> {
1552
+ return this.request('POST', '/publishing-pause', data, { acceptConfirmationPreview: true });
1553
+ }
1554
+
1555
+ async publishMissedPausedPosts(data: {
1556
+ post_ids: number[];
1557
+ workspace_id?: string;
1558
+ confirm: true;
1559
+ }): Promise<PublishMissedResponse> {
1560
+ return this.request('POST', '/publishing-pause/publish-missed', data);
1561
+ }
1562
+
1414
1563
  async getSubscription(): Promise<SubscriptionSummaryResponse> {
1415
1564
  return this.request('GET', '/subscription');
1416
1565
  }
@@ -1569,11 +1718,13 @@ export class PosterlyClient {
1569
1718
  account_id?: string | number;
1570
1719
  status?: 'pending' | 'scheduled' | 'dismissed';
1571
1720
  limit?: number;
1721
+ offset?: number;
1572
1722
  }): Promise<PostSuggestionsResponse> {
1573
1723
  const searchParams = new URLSearchParams();
1574
1724
  if (params?.account_id) searchParams.set('account_id', String(params.account_id));
1575
1725
  if (params?.status) searchParams.set('status', params.status);
1576
1726
  if (params?.limit) searchParams.set('limit', String(params.limit));
1727
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1577
1728
  const qs = searchParams.toString();
1578
1729
  return this.request('GET', `/suggestions${qs ? `?${qs}` : ''}`);
1579
1730
  }
@@ -1582,32 +1733,26 @@ export class PosterlyClient {
1582
1733
  return this.request('POST', `/suggestions/${encodeURIComponent(id)}/dismiss`, {});
1583
1734
  }
1584
1735
 
1585
- async listPosts(params?: {
1586
- status?: string;
1587
- platform?: string;
1588
- account_id?: string;
1736
+ async listPosts(params?: ListPostsParams & {
1589
1737
  limit?: number;
1590
1738
  offset?: number;
1591
- workspace_id?: string;
1592
- approval_status?: string;
1593
- approval_waiting_on?: string;
1594
- brand_id?: string;
1595
- }): Promise<{ posts: Post[]; total: number }> {
1596
- const searchParams = new URLSearchParams();
1597
- if (params?.status) searchParams.set('status', params.status);
1598
- if (params?.platform) searchParams.set('platform', params.platform);
1599
- if (params?.account_id) searchParams.set('account_id', params.account_id);
1739
+ order?: 'asc' | 'desc';
1740
+ }): Promise<ListPostsResponse> {
1741
+ const searchParams = postFilterParams(params);
1600
1742
  if (params?.limit) searchParams.set('limit', String(params.limit));
1601
1743
  if (params?.offset) searchParams.set('offset', String(params.offset));
1602
- if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
1603
- if (params?.approval_status) searchParams.set('approval_status', params.approval_status);
1604
- if (params?.approval_waiting_on) searchParams.set('approval_waiting_on', params.approval_waiting_on);
1605
- if (params?.brand_id) searchParams.set('brand_id', params.brand_id);
1744
+ if (params?.order) searchParams.set('order', params.order);
1606
1745
 
1607
1746
  const qs = searchParams.toString();
1608
1747
  return this.request('GET', `/posts${qs ? `?${qs}` : ''}`);
1609
1748
  }
1610
1749
 
1750
+ /** GET /posts/counts: exact totals per day, status, account, and brand for a scheduled range. */
1751
+ async countPosts(params: ListPostsParams): Promise<PostCountsResponse> {
1752
+ const qs = postFilterParams(params).toString();
1753
+ return this.request('GET', `/posts/counts${qs ? `?${qs}` : ''}`);
1754
+ }
1755
+
1611
1756
  async createPost(data: CreatePostPayload): Promise<CreatePostResponse> {
1612
1757
  return this.request('POST', '/posts', data);
1613
1758
  }
@@ -1975,6 +2120,7 @@ export class PosterlyClient {
1975
2120
  since?: string;
1976
2121
  type?: 'all' | 'activity' | 'publish';
1977
2122
  limit?: number;
2123
+ offset?: number;
1978
2124
  }): Promise<ActivityResponse> {
1979
2125
  const searchParams = new URLSearchParams();
1980
2126
  if (params?.workspace_id) searchParams.set('workspace_id', params.workspace_id);
@@ -1982,17 +2128,20 @@ export class PosterlyClient {
1982
2128
  if (params?.since) searchParams.set('since', params.since);
1983
2129
  if (params?.type) searchParams.set('type', params.type);
1984
2130
  if (params?.limit) searchParams.set('limit', String(params.limit));
2131
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1985
2132
  const qs = searchParams.toString();
1986
2133
  return this.request('GET', `/activity${qs ? `?${qs}` : ''}`);
1987
2134
  }
1988
2135
 
1989
2136
  async getUpdates(params?: {
1990
2137
  limit?: number;
2138
+ offset?: number;
1991
2139
  since?: string;
1992
2140
  include_content?: boolean;
1993
2141
  }): Promise<UpdatesResponse> {
1994
2142
  const searchParams = new URLSearchParams();
1995
2143
  if (params?.limit) searchParams.set('limit', String(params.limit));
2144
+ if (params?.offset) searchParams.set('offset', String(params.offset));
1996
2145
  if (params?.since) searchParams.set('since', params.since);
1997
2146
  if (params?.include_content) searchParams.set('include_content', 'true');
1998
2147
  const qs = searchParams.toString();
@@ -2115,6 +2264,7 @@ export class PosterlyClient {
2115
2264
 
2116
2265
  async listMedia(params?: {
2117
2266
  limit?: number;
2267
+ offset?: number;
2118
2268
  dropSessionId?: string;
2119
2269
  }): Promise<{
2120
2270
  media: Array<{
@@ -2128,9 +2278,14 @@ export class PosterlyClient {
2128
2278
  drop_session_id: string | null;
2129
2279
  }>;
2130
2280
  total: number;
2281
+ limit?: number;
2282
+ offset?: number;
2283
+ has_more?: boolean;
2284
+ next_offset?: number | null;
2131
2285
  }> {
2132
2286
  const searchParams = new URLSearchParams();
2133
2287
  if (params?.limit) searchParams.set('limit', String(params.limit));
2288
+ if (params?.offset) searchParams.set('offset', String(params.offset));
2134
2289
  if (params?.dropSessionId) searchParams.set('drop_session_id', params.dropSessionId);
2135
2290
  const qs = searchParams.toString();
2136
2291
  return this.request('GET', `/media${qs ? `?${qs}` : ''}`);
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.4';
8
+ export const POSTERLY_MCP_VERSION = '0.49.0';