posterly-mcp-server 0.29.0 → 0.30.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.
package/README.md CHANGED
@@ -122,7 +122,7 @@ Add the same server definition to your Cursor MCP settings:
122
122
 
123
123
  ## Available tools
124
124
 
125
- `posterly-mcp-server@0.27.0` exposes 75 tools.
125
+ `posterly-mcp-server@0.30.0` exposes 76 tools.
126
126
 
127
127
  Public setup tools work before `POSTERLY_API_KEY` exists:
128
128
 
@@ -180,6 +180,7 @@ Authenticated tools require `POSTERLY_API_KEY`:
180
180
  - `get_video_options`
181
181
  - `run_video_function` (read-only Veo helpers for cost estimation and request validation)
182
182
  - `generate_video` (queues a cost-guarded Veo video job)
183
+ - `get_credits` (read-only AI credit balance)
183
184
  - `get_video_job` (poll one job or list recent jobs)
184
185
  - `get_account_analytics`
185
186
  - `get_post_analytics`
package/dist/index.js CHANGED
@@ -70,6 +70,7 @@ import { createConnectSessionTool } from './tools/create-connect-session.js';
70
70
  import { getConnectSessionTool } from './tools/get-connect-session.js';
71
71
  import { createApiKeyTool } from './tools/create-api-key.js';
72
72
  import { deleteApiKeyTool } from './tools/delete-api-key.js';
73
+ import { getCreditsTool } from './tools/get-credits.js';
73
74
  import { getSubscriptionTool } from './tools/get-subscription.js';
74
75
  import { cancelSubscriptionTool } from './tools/cancel-subscription.js';
75
76
  import { pauseSubscriptionTool } from './tools/pause-subscription.js';
@@ -193,6 +194,15 @@ server.tool(deleteApiKeyTool.name, deleteApiKeyTool.description, deleteApiKeyToo
193
194
  return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
194
195
  }
195
196
  });
197
+ server.tool(getCreditsTool.name, getCreditsTool.description, getCreditsTool.inputSchema.shape, getToolAnnotations(getCreditsTool.name), async () => {
198
+ try {
199
+ const text = await getCreditsTool.execute(client);
200
+ return { content: [{ type: 'text', text }] };
201
+ }
202
+ catch (err) {
203
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
204
+ }
205
+ });
196
206
  server.tool(getSubscriptionTool.name, getSubscriptionTool.description, getSubscriptionTool.inputSchema.shape, getToolAnnotations(getSubscriptionTool.name), async () => {
197
207
  try {
198
208
  const text = await getSubscriptionTool.execute(client);
@@ -331,6 +331,26 @@ export type SubscriptionSummary = {
331
331
  export type SubscriptionSummaryResponse = {
332
332
  subscription: SubscriptionSummary;
333
333
  };
334
+ export type CreditsSummaryResponse = {
335
+ credits: {
336
+ available: number;
337
+ included: {
338
+ monthly: number;
339
+ used: number;
340
+ remaining: number;
341
+ };
342
+ purchased_balance: number;
343
+ plan_tier: string;
344
+ is_trial: boolean;
345
+ period_start: string;
346
+ /** Null while trialing or on a plan with no included pool. */
347
+ resets_at: string | null;
348
+ workspace: {
349
+ role: string;
350
+ shared_wallet: boolean;
351
+ };
352
+ };
353
+ };
334
354
  export type CancelSubscriptionPayload = {
335
355
  reason: string;
336
356
  feedback?: string;
@@ -791,6 +811,7 @@ export declare class PosterlyClient {
791
811
  confirm: true;
792
812
  }): Promise<DeleteApiKeyResponse>;
793
813
  getSubscription(): Promise<SubscriptionSummaryResponse>;
814
+ getCredits(): Promise<CreditsSummaryResponse>;
794
815
  cancelSubscription(data: CancelSubscriptionPayload): Promise<CancelSubscriptionResponse>;
795
816
  pauseSubscription(): Promise<PauseSubscriptionResponse>;
796
817
  resumeSubscription(): Promise<ResumeSubscriptionResponse>;
@@ -125,6 +125,9 @@ export class PosterlyClient {
125
125
  async getSubscription() {
126
126
  return this.request('GET', '/subscription');
127
127
  }
128
+ async getCredits() {
129
+ return this.request('GET', '/credits');
130
+ }
128
131
  async cancelSubscription(data) {
129
132
  return this.request('POST', '/subscription/cancel', data);
130
133
  }
@@ -1 +1 @@
1
- export declare const POSTERLY_MCP_VERSION = "0.29.0";
1
+ export declare const POSTERLY_MCP_VERSION = "0.30.0";
@@ -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.29.0';
8
+ export const POSTERLY_MCP_VERSION = '0.30.0';
@@ -151,12 +151,20 @@ export function buildCreatePostPayload(input) {
151
151
  },
152
152
  };
153
153
  }
154
- if (!caption) {
155
- throw new Error('caption is required (or pass `thread_posts` for an X/Threads thread)');
154
+ // Mirror the server rule (lib/api-v1-post-create.ts): a post needs a caption
155
+ // OR media, not a caption unconditionally. Requiring one here made every
156
+ // media-only post impossible from the npm client, most visibly Instagram and
157
+ // Facebook Stories, which carry no caption at all. The dashboard composer and
158
+ // the hosted MCP have always allowed it; only this package disagreed.
159
+ const hasMedia = Boolean(input.media_url) || (input.media_urls?.length ?? 0) > 0;
160
+ if (!caption && !hasMedia) {
161
+ throw new Error('Provide caption or media (or pass `thread_posts` for an X/Threads thread). Media-only posts are valid, for example an Instagram or Facebook Story.');
156
162
  }
157
163
  return {
158
164
  ...rest,
159
- caption,
165
+ // The server treats a missing caption as empty content when media carries
166
+ // the post, so send '' rather than dropping the key.
167
+ caption: caption ?? '',
160
168
  post_type,
161
169
  ...(platform_settings || instagram_settings
162
170
  ? { settings: { ...(instagram_settings ? { __type: 'instagram', ...instagram_settings } : {}), ...(platform_settings || {}) } }
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod';
2
+ import type { PosterlyClient } from '../lib/api-client.js';
3
+ export declare const getCreditsTool: {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
7
+ execute(client: PosterlyClient): Promise<string>;
8
+ };
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import { formatDateTime, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
3
+ function formatCredits(result) {
4
+ const c = result.credits;
5
+ const inc = c.included;
6
+ return [
7
+ mdTitle('posterly AI credits'),
8
+ mdSection('Balance', mdKeyValue([
9
+ ['Available now', String(c.available)],
10
+ ['Included this period', `${inc.remaining} of ${inc.monthly} left (${inc.used} used)`],
11
+ ['Purchased packs', String(c.purchased_balance)],
12
+ ])),
13
+ mdSection('Plan', mdKeyValue([
14
+ ['Tier', c.plan_tier || 'unknown'],
15
+ ['Trial', c.is_trial ? 'yes' : 'no'],
16
+ [
17
+ 'Included pool resets',
18
+ c.resets_at ? formatDateTime(c.resets_at) : 'no monthly reset (trial or no included pool)',
19
+ ],
20
+ ['Your workspace role', c.workspace?.role || 'unknown'],
21
+ ])),
22
+ "Credits are a shared workspace wallet: every active member draws the same pool, and packs are added to the billing owner's balance.",
23
+ ].join('\n\n');
24
+ }
25
+ export const getCreditsTool = {
26
+ name: 'get_credits',
27
+ description: "Get the authenticated user's posterly AI credit balance: what is available to spend now, how much of the monthly included allowance is left, purchased pack balance, plan tier, and when the included pool next resets. Credits are a shared WORKSPACE wallet, so this reports the balance the caller can actually spend, not a personal figure. Read-only, spends nothing. Requires the billing:read scope.",
28
+ inputSchema: z.object({}),
29
+ async execute(client) {
30
+ const result = await client.getCredits();
31
+ return formatCredits(result);
32
+ },
33
+ };
@@ -34,10 +34,12 @@ declare const instagramSettingsSchema: z.ZodObject<{
34
34
  first_comment: z.ZodOptional<z.ZodString>;
35
35
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
36
36
  reel_cover_url: z.ZodOptional<z.ZodString>;
37
+ reel_cover_method: z.ZodOptional<z.ZodString>;
37
38
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
38
39
  }, "strip", z.ZodTypeAny, {
39
40
  post_type?: "post" | "feed" | "story" | "reel" | "carousel" | undefined;
40
41
  reel_cover_url?: string | undefined;
42
+ reel_cover_method?: string | undefined;
41
43
  reel_thumb_offset?: number | undefined;
42
44
  __type?: "instagram" | "instagram-standalone" | undefined;
43
45
  is_trial_reel?: boolean | undefined;
@@ -57,6 +59,7 @@ declare const instagramSettingsSchema: z.ZodObject<{
57
59
  }, {
58
60
  post_type?: "post" | "feed" | "story" | "reel" | "carousel" | undefined;
59
61
  reel_cover_url?: string | undefined;
62
+ reel_cover_method?: string | undefined;
60
63
  reel_thumb_offset?: number | undefined;
61
64
  __type?: "instagram" | "instagram-standalone" | undefined;
62
65
  is_trial_reel?: boolean | undefined;
@@ -80,6 +83,7 @@ declare const platformSettingsSchema: z.ZodObject<{
80
83
  content_type: z.ZodOptional<z.ZodString>;
81
84
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
82
85
  reel_cover_url: z.ZodOptional<z.ZodString>;
86
+ reel_cover_method: z.ZodOptional<z.ZodString>;
83
87
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
84
88
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
85
89
  poll: z.ZodOptional<z.ZodObject<{
@@ -156,6 +160,7 @@ declare const platformSettingsSchema: z.ZodObject<{
156
160
  content_type: z.ZodOptional<z.ZodString>;
157
161
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
158
162
  reel_cover_url: z.ZodOptional<z.ZodString>;
163
+ reel_cover_method: z.ZodOptional<z.ZodString>;
159
164
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
160
165
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
161
166
  poll: z.ZodOptional<z.ZodObject<{
@@ -232,6 +237,7 @@ declare const platformSettingsSchema: z.ZodObject<{
232
237
  content_type: z.ZodOptional<z.ZodString>;
233
238
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
234
239
  reel_cover_url: z.ZodOptional<z.ZodString>;
240
+ reel_cover_method: z.ZodOptional<z.ZodString>;
235
241
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
236
242
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
237
243
  poll: z.ZodOptional<z.ZodObject<{
@@ -313,6 +319,9 @@ export declare const updatePostTool: {
313
319
  media_url: z.ZodOptional<z.ZodString>;
314
320
  media_urls: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
315
321
  post_type: z.ZodOptional<z.ZodString>;
322
+ reel_cover_url: z.ZodOptional<z.ZodString>;
323
+ reel_cover_method: z.ZodOptional<z.ZodString>;
324
+ reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
316
325
  instagram_settings: z.ZodOptional<z.ZodObject<{
317
326
  __type: z.ZodOptional<z.ZodEnum<["instagram", "instagram-standalone"]>>;
318
327
  post_type: z.ZodOptional<z.ZodEnum<["post", "feed", "story", "reel", "carousel"]>>;
@@ -347,10 +356,12 @@ export declare const updatePostTool: {
347
356
  first_comment: z.ZodOptional<z.ZodString>;
348
357
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
349
358
  reel_cover_url: z.ZodOptional<z.ZodString>;
359
+ reel_cover_method: z.ZodOptional<z.ZodString>;
350
360
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
351
361
  }, "strip", z.ZodTypeAny, {
352
362
  post_type?: "post" | "feed" | "story" | "reel" | "carousel" | undefined;
353
363
  reel_cover_url?: string | undefined;
364
+ reel_cover_method?: string | undefined;
354
365
  reel_thumb_offset?: number | undefined;
355
366
  __type?: "instagram" | "instagram-standalone" | undefined;
356
367
  is_trial_reel?: boolean | undefined;
@@ -370,6 +381,7 @@ export declare const updatePostTool: {
370
381
  }, {
371
382
  post_type?: "post" | "feed" | "story" | "reel" | "carousel" | undefined;
372
383
  reel_cover_url?: string | undefined;
384
+ reel_cover_method?: string | undefined;
373
385
  reel_thumb_offset?: number | undefined;
374
386
  __type?: "instagram" | "instagram-standalone" | undefined;
375
387
  is_trial_reel?: boolean | undefined;
@@ -393,6 +405,7 @@ export declare const updatePostTool: {
393
405
  content_type: z.ZodOptional<z.ZodString>;
394
406
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
395
407
  reel_cover_url: z.ZodOptional<z.ZodString>;
408
+ reel_cover_method: z.ZodOptional<z.ZodString>;
396
409
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
397
410
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
398
411
  poll: z.ZodOptional<z.ZodObject<{
@@ -469,6 +482,7 @@ export declare const updatePostTool: {
469
482
  content_type: z.ZodOptional<z.ZodString>;
470
483
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
471
484
  reel_cover_url: z.ZodOptional<z.ZodString>;
485
+ reel_cover_method: z.ZodOptional<z.ZodString>;
472
486
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
473
487
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
474
488
  poll: z.ZodOptional<z.ZodObject<{
@@ -545,6 +559,7 @@ export declare const updatePostTool: {
545
559
  content_type: z.ZodOptional<z.ZodString>;
546
560
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
547
561
  reel_cover_url: z.ZodOptional<z.ZodString>;
562
+ reel_cover_method: z.ZodOptional<z.ZodString>;
548
563
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
549
564
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
550
565
  poll: z.ZodOptional<z.ZodObject<{
@@ -624,10 +639,14 @@ export declare const updatePostTool: {
624
639
  media_url?: string | undefined;
625
640
  media_urls?: string[] | undefined;
626
641
  post_type?: string | undefined;
642
+ reel_cover_url?: string | undefined;
643
+ reel_cover_method?: string | undefined;
644
+ reel_thumb_offset?: number | undefined;
627
645
  caption?: string | undefined;
628
646
  instagram_settings?: {
629
647
  post_type?: "post" | "feed" | "story" | "reel" | "carousel" | undefined;
630
648
  reel_cover_url?: string | undefined;
649
+ reel_cover_method?: string | undefined;
631
650
  reel_thumb_offset?: number | undefined;
632
651
  __type?: "instagram" | "instagram-standalone" | undefined;
633
652
  is_trial_reel?: boolean | undefined;
@@ -651,6 +670,7 @@ export declare const updatePostTool: {
651
670
  content_type: z.ZodOptional<z.ZodString>;
652
671
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
653
672
  reel_cover_url: z.ZodOptional<z.ZodString>;
673
+ reel_cover_method: z.ZodOptional<z.ZodString>;
654
674
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
655
675
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
656
676
  poll: z.ZodOptional<z.ZodObject<{
@@ -729,10 +749,14 @@ export declare const updatePostTool: {
729
749
  media_url?: string | undefined;
730
750
  media_urls?: string[] | undefined;
731
751
  post_type?: string | undefined;
752
+ reel_cover_url?: string | undefined;
753
+ reel_cover_method?: string | undefined;
754
+ reel_thumb_offset?: number | undefined;
732
755
  caption?: string | undefined;
733
756
  instagram_settings?: {
734
757
  post_type?: "post" | "feed" | "story" | "reel" | "carousel" | undefined;
735
758
  reel_cover_url?: string | undefined;
759
+ reel_cover_method?: string | undefined;
736
760
  reel_thumb_offset?: number | undefined;
737
761
  __type?: "instagram" | "instagram-standalone" | undefined;
738
762
  is_trial_reel?: boolean | undefined;
@@ -756,6 +780,7 @@ export declare const updatePostTool: {
756
780
  content_type: z.ZodOptional<z.ZodString>;
757
781
  media_alt_texts: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
758
782
  reel_cover_url: z.ZodOptional<z.ZodString>;
783
+ reel_cover_method: z.ZodOptional<z.ZodString>;
759
784
  reel_thumb_offset: z.ZodOptional<z.ZodNumber>;
760
785
  reply_settings: z.ZodOptional<z.ZodEnum<["everyone", "following", "mentionedUsers", "subscribers", "verified"]>>;
761
786
  poll: z.ZodOptional<z.ZodObject<{
@@ -14,6 +14,7 @@ const instagramSettingsSchema = z.object({
14
14
  first_comment: z.string().optional(),
15
15
  media_alt_texts: z.record(z.string(), z.string()).optional(),
16
16
  reel_cover_url: z.string().optional(),
17
+ reel_cover_method: z.string().optional(),
17
18
  reel_thumb_offset: z.number().nonnegative().optional(),
18
19
  });
19
20
  const platformSettingsSchema = z.object({
@@ -22,6 +23,7 @@ const platformSettingsSchema = z.object({
22
23
  content_type: z.string().optional(),
23
24
  media_alt_texts: z.record(z.string(), z.string()).optional(),
24
25
  reel_cover_url: z.string().optional(),
26
+ reel_cover_method: z.string().optional(),
25
27
  reel_thumb_offset: z.number().nonnegative().optional(),
26
28
  reply_settings: z.enum(['everyone', 'following', 'mentionedUsers', 'subscribers', 'verified']).optional(),
27
29
  poll: z.object({ options: z.array(z.string()), duration_minutes: z.number().positive().optional(), question: z.string().optional(), duration: z.enum(['ONE_DAY', 'THREE_DAYS', 'SEVEN_DAYS', 'FOURTEEN_DAYS']).optional(), allows_multiple_answers: z.boolean().optional() }).optional(),
@@ -83,9 +85,15 @@ export const updatePostTool = {
83
85
  .string()
84
86
  .optional()
85
87
  .describe('New post type: text, image, video, carousel, reel, story, story_series, document, photo, or cover_photo'),
88
+ // Reel cover controls are also accepted top-level, matching the hosted
89
+ // endpoint. An agent reading the hosted schema will reach for these names,
90
+ // and scripts/check-mcp-schema-parity.ts enforces that both surfaces agree.
91
+ reel_cover_url: z.string().optional().describe('Cover image URL for a reel.'),
92
+ reel_cover_method: z.string().optional().describe('How the reel cover is chosen, e.g. an uploaded image or a frame offset.'),
93
+ reel_thumb_offset: z.number().nonnegative().optional().describe('Frame offset in milliseconds to use as the reel cover.'),
86
94
  instagram_settings: instagramSettingsSchema
87
95
  .optional()
88
- .describe('Instagram-specific updates: post_type, collaborators, user_tags, first_comment, media_alt_texts, is_trial_reel, graduation_strategy (defaults to MANUAL), reel_cover_url, reel_thumb_offset.'),
96
+ .describe('Instagram-specific updates: post_type, collaborators, user_tags, first_comment, media_alt_texts, is_trial_reel, graduation_strategy (defaults to MANUAL), reel_cover_url, reel_cover_method, reel_thumb_offset.'),
89
97
  platform_settings: platformSettingsSchema
90
98
  .optional()
91
99
  .describe('Platform-specific settings to merge into the post. Prefer this over raw metadata.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posterly-mcp-server",
3
- "version": "0.29.0",
3
+ "version": "0.30.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",
@@ -32,7 +32,8 @@
32
32
  "build": "tsc",
33
33
  "dev": "tsc --watch",
34
34
  "start": "node dist/index.js",
35
- "test:annotations": "npm run build && node scripts/test-tool-annotations.mjs"
35
+ "test:annotations": "npm run build && node scripts/test-tool-annotations.mjs",
36
+ "test:payloads": "npm run build && node scripts/test-payload-builders.mjs"
36
37
  },
37
38
  "dependencies": {
38
39
  "@modelcontextprotocol/sdk": "^1.12.1",
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Behavioural tests for the payload builders.
3
+ *
4
+ * scripts/check-mcp-schema-parity.ts compares the JSON Schema each surface
5
+ * publishes, which catches a field going missing or an enum narrowing. It does
6
+ * NOT catch validation written as code rather than schema, and that is exactly
7
+ * how the caption bug shipped: buildCreatePostPayload threw
8
+ * "caption is required" for every post without caption text while the schema
9
+ * happily declared caption optional. Nothing compared behaviour, so the npm
10
+ * package rejected every media-only post (Instagram and Facebook Stories carry
11
+ * no caption) for as long as it did.
12
+ *
13
+ * These tests pin the rules the server actually applies, so a future edit that
14
+ * tightens the client past the server fails here.
15
+ */
16
+
17
+ import assert from 'node:assert/strict';
18
+ import { buildCreatePostPayload } from '../dist/tools/create-post.js';
19
+
20
+ let failures = 0;
21
+ function check(name, fn) {
22
+ try {
23
+ fn();
24
+ console.log(`PASS ${name}`);
25
+ } catch (error) {
26
+ failures += 1;
27
+ console.error(`FAIL ${name}\n ${error.message}`);
28
+ }
29
+ }
30
+
31
+ const IMAGE = 'https://assets.poster.ly/users/test/a.jpg';
32
+
33
+ // The server rule (lib/api-v1-post-create.ts) is "provide caption or media".
34
+ // Media-only posts are valid and must not be rejected by the client.
35
+ check('media-only post is accepted (Instagram Story shape)', () => {
36
+ const payload = buildCreatePostPayload({
37
+ platform: 'instagram',
38
+ media_url: IMAGE,
39
+ post_type: 'story',
40
+ instagram_settings: { post_type: 'story' },
41
+ });
42
+ assert.equal(typeof payload.caption, 'string', 'caption should be sent as a string');
43
+ assert.equal(payload.post_type, 'story');
44
+ });
45
+
46
+ check('media-only post is accepted (Facebook Story shape)', () => {
47
+ const payload = buildCreatePostPayload({
48
+ platform: 'facebook',
49
+ media_url: IMAGE,
50
+ post_type: 'story',
51
+ platform_settings: { __type: 'facebook', post_type: 'story' },
52
+ });
53
+ assert.equal(typeof payload.caption, 'string');
54
+ });
55
+
56
+ check('media_urls-only post is accepted', () => {
57
+ const payload = buildCreatePostPayload({ platform: 'instagram', media_urls: [IMAGE] });
58
+ assert.equal(typeof payload.caption, 'string');
59
+ });
60
+
61
+ check('caption-only post is accepted', () => {
62
+ const payload = buildCreatePostPayload({ platform: 'linkedin', caption: 'hello' });
63
+ assert.equal(payload.caption, 'hello');
64
+ });
65
+
66
+ check('post with neither caption nor media is rejected', () => {
67
+ assert.throws(
68
+ () => buildCreatePostPayload({ platform: 'instagram' }),
69
+ /caption or media/i,
70
+ 'should reject, and the message should tell the caller media is an option',
71
+ );
72
+ });
73
+
74
+ check('thread_posts still bypasses the caption rule', () => {
75
+ const payload = buildCreatePostPayload({
76
+ platform: 'twitter',
77
+ thread_posts: ['one', 'two'],
78
+ });
79
+ assert.equal(payload.caption, 'one');
80
+ assert.equal(payload.post_type, 'x_thread');
81
+ });
82
+
83
+ if (failures > 0) {
84
+ console.error(`\n${failures} payload-builder test(s) failed.`);
85
+ process.exit(1);
86
+ }
87
+ console.log(`\nOK payload builders honour the server's "caption or media" rule.`);
@@ -12,7 +12,7 @@ try {
12
12
  await client.connect(transport);
13
13
  const { tools } = await client.listTools();
14
14
 
15
- assert.equal(tools.length, 75);
15
+ assert.equal(tools.length, 76);
16
16
  for (const tool of tools) {
17
17
  assert(tool.annotations, `${tool.name} is missing annotations`);
18
18
  assert(tool.annotations.title, `${tool.name} is missing an annotation title`);
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.29.0",
6
+ "version": "0.30.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.29.0",
17
+ "version": "0.30.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
package/src/index.ts CHANGED
@@ -71,6 +71,7 @@ import { createConnectSessionTool } from './tools/create-connect-session.js';
71
71
  import { getConnectSessionTool } from './tools/get-connect-session.js';
72
72
  import { createApiKeyTool } from './tools/create-api-key.js';
73
73
  import { deleteApiKeyTool } from './tools/delete-api-key.js';
74
+ import { getCreditsTool } from './tools/get-credits.js';
74
75
  import { getSubscriptionTool } from './tools/get-subscription.js';
75
76
  import { cancelSubscriptionTool } from './tools/cancel-subscription.js';
76
77
  import { pauseSubscriptionTool } from './tools/pause-subscription.js';
@@ -269,6 +270,21 @@ server.tool(
269
270
  }
270
271
  );
271
272
 
273
+ server.tool(
274
+ getCreditsTool.name,
275
+ getCreditsTool.description,
276
+ getCreditsTool.inputSchema.shape,
277
+ getToolAnnotations(getCreditsTool.name),
278
+ async () => {
279
+ try {
280
+ const text = await getCreditsTool.execute(client);
281
+ return { content: [{ type: 'text' as const, text }] };
282
+ } catch (err: any) {
283
+ return { content: [{ type: 'text' as const, text: `Error: ${err.message}` }], isError: true };
284
+ }
285
+ }
286
+ );
287
+
272
288
  server.tool(
273
289
  getSubscriptionTool.name,
274
290
  getSubscriptionTool.description,
@@ -356,6 +356,20 @@ export type SubscriptionSummaryResponse = {
356
356
  subscription: SubscriptionSummary;
357
357
  };
358
358
 
359
+ export type CreditsSummaryResponse = {
360
+ credits: {
361
+ available: number;
362
+ included: { monthly: number; used: number; remaining: number };
363
+ purchased_balance: number;
364
+ plan_tier: string;
365
+ is_trial: boolean;
366
+ period_start: string;
367
+ /** Null while trialing or on a plan with no included pool. */
368
+ resets_at: string | null;
369
+ workspace: { role: string; shared_wallet: boolean };
370
+ };
371
+ };
372
+
359
373
  export type CancelSubscriptionPayload = {
360
374
  reason: string;
361
375
  feedback?: string;
@@ -968,6 +982,10 @@ export class PosterlyClient {
968
982
  return this.request('GET', '/subscription');
969
983
  }
970
984
 
985
+ async getCredits(): Promise<CreditsSummaryResponse> {
986
+ return this.request('GET', '/credits');
987
+ }
988
+
971
989
  async cancelSubscription(data: CancelSubscriptionPayload): Promise<CancelSubscriptionResponse> {
972
990
  return this.request('POST', '/subscription/cancel', data);
973
991
  }
@@ -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.29.0';
8
+ export const POSTERLY_MCP_VERSION = '0.30.0';
@@ -164,13 +164,23 @@ export function buildCreatePostPayload(input: CreatePostPayloadInput | CreatePos
164
164
  };
165
165
  }
166
166
 
167
- if (!caption) {
168
- throw new Error('caption is required (or pass `thread_posts` for an X/Threads thread)');
167
+ // Mirror the server rule (lib/api-v1-post-create.ts): a post needs a caption
168
+ // OR media, not a caption unconditionally. Requiring one here made every
169
+ // media-only post impossible from the npm client, most visibly Instagram and
170
+ // Facebook Stories, which carry no caption at all. The dashboard composer and
171
+ // the hosted MCP have always allowed it; only this package disagreed.
172
+ const hasMedia = Boolean(input.media_url) || (input.media_urls?.length ?? 0) > 0;
173
+ if (!caption && !hasMedia) {
174
+ throw new Error(
175
+ 'Provide caption or media (or pass `thread_posts` for an X/Threads thread). Media-only posts are valid, for example an Instagram or Facebook Story.',
176
+ );
169
177
  }
170
178
 
171
179
  return {
172
180
  ...rest,
173
- caption,
181
+ // The server treats a missing caption as empty content when media carries
182
+ // the post, so send '' rather than dropping the key.
183
+ caption: caption ?? '',
174
184
  post_type,
175
185
  ...(platform_settings || instagram_settings
176
186
  ? { settings: { ...(instagram_settings ? { __type: 'instagram', ...instagram_settings } : {}), ...(platform_settings || {}) } }
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import type { CreditsSummaryResponse, PosterlyClient } from '../lib/api-client.js';
3
+ import { formatDateTime, mdKeyValue, mdSection, mdTitle } from '../lib/format.js';
4
+
5
+ function formatCredits(result: CreditsSummaryResponse): string {
6
+ const c = result.credits;
7
+ const inc = c.included;
8
+ return [
9
+ mdTitle('posterly AI credits'),
10
+ mdSection('Balance', mdKeyValue([
11
+ ['Available now', String(c.available)],
12
+ ['Included this period', `${inc.remaining} of ${inc.monthly} left (${inc.used} used)`],
13
+ ['Purchased packs', String(c.purchased_balance)],
14
+ ])),
15
+ mdSection('Plan', mdKeyValue([
16
+ ['Tier', c.plan_tier || 'unknown'],
17
+ ['Trial', c.is_trial ? 'yes' : 'no'],
18
+ [
19
+ 'Included pool resets',
20
+ c.resets_at ? formatDateTime(c.resets_at) : 'no monthly reset (trial or no included pool)',
21
+ ],
22
+ ['Your workspace role', c.workspace?.role || 'unknown'],
23
+ ])),
24
+ "Credits are a shared workspace wallet: every active member draws the same pool, and packs are added to the billing owner's balance.",
25
+ ].join('\n\n');
26
+ }
27
+
28
+ export const getCreditsTool = {
29
+ name: 'get_credits',
30
+ description:
31
+ "Get the authenticated user's posterly AI credit balance: what is available to spend now, how much of the monthly included allowance is left, purchased pack balance, plan tier, and when the included pool next resets. Credits are a shared WORKSPACE wallet, so this reports the balance the caller can actually spend, not a personal figure. Read-only, spends nothing. Requires the billing:read scope.",
32
+ inputSchema: z.object({}),
33
+
34
+ async execute(client: PosterlyClient) {
35
+ const result = await client.getCredits();
36
+ return formatCredits(result);
37
+ },
38
+ };
@@ -16,6 +16,7 @@ const instagramSettingsSchema = z.object({
16
16
  first_comment: z.string().optional(),
17
17
  media_alt_texts: z.record(z.string(), z.string()).optional(),
18
18
  reel_cover_url: z.string().optional(),
19
+ reel_cover_method: z.string().optional(),
19
20
  reel_thumb_offset: z.number().nonnegative().optional(),
20
21
  });
21
22
 
@@ -25,6 +26,7 @@ const platformSettingsSchema = z.object({
25
26
  content_type: z.string().optional(),
26
27
  media_alt_texts: z.record(z.string(), z.string()).optional(),
27
28
  reel_cover_url: z.string().optional(),
29
+ reel_cover_method: z.string().optional(),
28
30
  reel_thumb_offset: z.number().nonnegative().optional(),
29
31
  reply_settings: z.enum(['everyone', 'following', 'mentionedUsers', 'subscribers', 'verified']).optional(),
30
32
  poll: z.object({ options: z.array(z.string()), duration_minutes: z.number().positive().optional(), question: z.string().optional(), duration: z.enum(['ONE_DAY', 'THREE_DAYS', 'SEVEN_DAYS', 'FOURTEEN_DAYS']).optional(), allows_multiple_answers: z.boolean().optional() }).optional(),
@@ -88,9 +90,15 @@ export const updatePostTool = {
88
90
  .string()
89
91
  .optional()
90
92
  .describe('New post type: text, image, video, carousel, reel, story, story_series, document, photo, or cover_photo'),
93
+ // Reel cover controls are also accepted top-level, matching the hosted
94
+ // endpoint. An agent reading the hosted schema will reach for these names,
95
+ // and scripts/check-mcp-schema-parity.ts enforces that both surfaces agree.
96
+ reel_cover_url: z.string().optional().describe('Cover image URL for a reel.'),
97
+ reel_cover_method: z.string().optional().describe('How the reel cover is chosen, e.g. an uploaded image or a frame offset.'),
98
+ reel_thumb_offset: z.number().nonnegative().optional().describe('Frame offset in milliseconds to use as the reel cover.'),
91
99
  instagram_settings: instagramSettingsSchema
92
100
  .optional()
93
- .describe('Instagram-specific updates: post_type, collaborators, user_tags, first_comment, media_alt_texts, is_trial_reel, graduation_strategy (defaults to MANUAL), reel_cover_url, reel_thumb_offset.'),
101
+ .describe('Instagram-specific updates: post_type, collaborators, user_tags, first_comment, media_alt_texts, is_trial_reel, graduation_strategy (defaults to MANUAL), reel_cover_url, reel_cover_method, reel_thumb_offset.'),
94
102
  platform_settings: platformSettingsSchema
95
103
  .optional()
96
104
  .describe('Platform-specific settings to merge into the post. Prefer this over raw metadata.'),
@@ -12,6 +12,7 @@
12
12
  "get_connect_session",
13
13
  "create_api_key",
14
14
  "delete_api_key",
15
+ "get_credits",
15
16
  "get_subscription",
16
17
  "cancel_subscription",
17
18
  "pause_subscription",
@@ -84,6 +85,7 @@
84
85
  "list_accounts",
85
86
  "get_connect_link",
86
87
  "get_connect_session",
88
+ "get_credits",
87
89
  "get_subscription",
88
90
  "list_oauth_clients",
89
91
  "list_platforms",
@@ -162,6 +164,7 @@
162
164
  "disconnect_account",
163
165
  "create_connect_session",
164
166
  "get_connect_session",
167
+ "get_credits",
165
168
  "get_subscription",
166
169
  "cancel_subscription",
167
170
  "pause_subscription",