@omnisocials/mcp-server 1.20.0 → 1.21.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.
@@ -83,6 +83,91 @@ const LINKEDIN_PAGE_OPTIONS = z
83
83
  })
84
84
  .optional()
85
85
  .describe("LinkedIn Company Page options");
86
+ const LINKEDIN_POLL_OPTIONS = z
87
+ .object({
88
+ question: z.string().max(140).describe("The poll question (max 140 characters)."),
89
+ options: z
90
+ .array(z.string().max(30))
91
+ .min(2)
92
+ .max(4)
93
+ .describe("2-4 answer options (max 30 characters each)."),
94
+ duration: z
95
+ .enum(["ONE_DAY", "THREE_DAYS", "SEVEN_DAYS", "FOURTEEN_DAYS"])
96
+ .describe("How long the poll stays open for votes."),
97
+ })
98
+ .nullable()
99
+ .optional()
100
+ .describe("Non-sponsored LinkedIn poll, posted to whichever of `linkedin`/`linkedin_page` is selected in `accounts`. Mutually exclusive with media and a link share — a poll takes priority over both at publish time. Still requires `content.linkedin` (or `content.default`) as the post's caption; the poll itself only carries the question/options/duration. Pass `null` on update_post to clear a poll and revert to a normal post.");
101
+ // Per-platform option schemas shared verbatim by create_post,
102
+ // create_and_publish_post and update_post (they were byte-identical in all
103
+ // three). Platform blocks that genuinely differ per tool (instagram, youtube,
104
+ // google_business, and the thread_parts wrappers with their per-tool
105
+ // nullability/descriptions) stay inline in each tool.
106
+ const TIKTOK_OPTIONS = z.object({
107
+ privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
108
+ disable_comment: z.boolean().optional(),
109
+ disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
110
+ disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
111
+ video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
112
+ is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
113
+ brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
114
+ brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
115
+ auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
116
+ }).optional().describe("TikTok options");
117
+ const PINTEREST_OPTIONS = z.object({
118
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
119
+ title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
120
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
121
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
122
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
123
+ }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate.");
124
+ // One Instagram photo tag. The wrapping z.array(...).describe(...) stays
125
+ // per-tool because update_post documents replace/clear semantics.
126
+ const USER_TAG_ENTRY = z.object({
127
+ username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
128
+ x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
129
+ y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
130
+ image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
131
+ });
132
+ // Inner thread-part schemas for the chained-thread platforms. The wrapping
133
+ // z.array(...).min(2).max(25)[.nullable()].optional().describe(...) stays
134
+ // per-tool (update_post allows null to clear the thread).
135
+ const X_THREAD_PART = z.object({
136
+ text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
137
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
138
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
139
+ });
140
+ const BLUESKY_THREAD_PART = z.object({
141
+ text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
142
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
143
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
144
+ });
145
+ const MASTODON_THREAD_PART = z.object({
146
+ text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
147
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
148
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
149
+ });
150
+ const GOOGLE_BUSINESS_OPTIONS = z.object({
151
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
152
+ cta: z.object({
153
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]).describe("Button rendered under the caption. Phone numbers belong on a CALL button and links on a LEARN_MORE / BOOK / SHOP button — they're rejected if placed in the caption text."),
154
+ url: z.string().optional().describe("Required for every actionType except CALL. Must be http:// or https://. CALL uses the location's phone number from the business profile."),
155
+ }).optional().describe("Optional call-to-action button."),
156
+ event: z.object({
157
+ title: z.string().max(58).describe("Event title (max 58 chars)."),
158
+ schedule: z.object({
159
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
160
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
161
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
162
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
163
+ }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
164
+ }).optional().describe("Required when topic_type is EVENT."),
165
+ offer: z.object({
166
+ couponCode: z.string().max(58).optional(),
167
+ redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
168
+ termsConditions: z.string().max(4000).optional(),
169
+ }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
170
+ }).optional().describe("Google Business Profile options. Use to publish EVENT or OFFER posts, attach a CTA button, or both. Shape mirrors Google's LocalPost resource (see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost).\n\nGoogle Business caption rules (enforced at scheduling — text that violates these will return a `validation_error` 400 before the post is saved):\n • Phone numbers in the caption are rejected — use a CALL button instead.\n • Inline URLs / bare domains / emails are rejected — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons instead.\n • Caption max 1500 characters.\n • Media is optional (text-only posts are allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling — otherwise returns a 400.");
86
171
  // Render full content for get_post — preserves per-platform overrides so Claude
87
172
  // can see custom captions (e.g. a shorter X version) instead of only `default`.
88
173
  function renderContent(content) {
@@ -152,7 +237,7 @@ export function registerPostTools(server, getClient) {
152
237
  content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
153
238
  };
154
239
  }
155
- const posts = Array.isArray(result.data) ? result.data : result.data?.posts || [];
240
+ const posts = Array.isArray(result.data) ? result.data : [];
156
241
  if (!posts.length) {
157
242
  return {
158
243
  content: [{ type: "text", text: `No posts found${params.status ? ` with status "${params.status}"` : ""}.` }],
@@ -164,12 +249,13 @@ export function registerPostTools(server, getClient) {
164
249
  md += `|---|---------|----------|--------|------|----|\n`;
165
250
  for (let i = 0; i < posts.length; i++) {
166
251
  const p = posts[i];
167
- const isObj = typeof p.content === "object" && p.content !== null;
168
- const rawContent = isObj
169
- ? (p.content.default || Object.values(p.content).find((v) => typeof v === "string" && v) || "")
170
- : (p.content || "");
171
- const hasOverrides = isObj && Object.keys(p.content).filter((k) => k !== "default" && typeof p.content[k] === "string" && p.content[k]).length > 0;
172
- const threadParts = Array.isArray(p.x?.thread_parts) ? p.x.thread_parts : [];
252
+ const contentObj = typeof p.content === "object" && p.content !== null ? p.content : null;
253
+ const rawContent = contentObj
254
+ ? (contentObj.default || Object.values(contentObj).find((v) => typeof v === "string" && v) || "")
255
+ : (typeof p.content === "string" ? p.content : "");
256
+ const hasOverrides = !!contentObj && Object.keys(contentObj).filter((k) => k !== "default" && typeof contentObj[k] === "string" && contentObj[k]).length > 0;
257
+ const xParts = p.x?.thread_parts;
258
+ const threadParts = Array.isArray(xParts) ? xParts : [];
173
259
  const isThread = threadParts.length >= 2;
174
260
  let content;
175
261
  if (isThread && !rawContent) {
@@ -188,7 +274,7 @@ export function registerPostTools(server, getClient) {
188
274
  content: [{ type: "text", text: md }],
189
275
  };
190
276
  });
191
- server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs + any per-media alt text), first comment, Instagram collaborators/user tags/location/Trial Reel state/Reel cover (thumbnail_type + thumb_offset in ms), dates and live URLs, enough to fully verify a scheduled post without opening the dashboard. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
277
+ server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs + any per-media alt text), first comment, Instagram collaborators/user tags/location/Trial Reel state/Reel cover (thumbnail_type + thumb_offset in ms), dates and live URLs, enough to fully verify a scheduled post without opening the dashboard. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. A LinkedIn poll is rendered under `### LinkedIn Poll` with the question, options, and duration. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
192
278
  id: z.string().describe("The post ID"),
193
279
  }, async ({ id }) => {
194
280
  const result = await getClient().getPost(id);
@@ -216,7 +302,7 @@ export function registerPostTools(server, getClient) {
216
302
  md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
217
303
  if (p.published_at)
218
304
  md += `| **Published** | ${formatDateTime(p.published_at)} |\n`;
219
- md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
305
+ md += `| **Created** | ${formatDateTime(p.created_at ?? null)} |\n`;
220
306
  if (appUrl)
221
307
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
222
308
  // Retry linkage: a "published" post with `retries` set and empty
@@ -260,7 +346,7 @@ export function registerPostTools(server, getClient) {
260
346
  md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
261
347
  }
262
348
  items.forEach((m, i) => {
263
- const alt = typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
349
+ const alt = typeof m !== "string" && typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
264
350
  md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}${alt}\n`;
265
351
  });
266
352
  md += `\n`;
@@ -268,9 +354,8 @@ export function registerPostTools(server, getClient) {
268
354
  md = md.trimEnd();
269
355
  }
270
356
  // X thread: when present, the canonical tweet text lives here, not in `content`.
271
- const threadParts = Array.isArray(p.x?.thread_parts)
272
- ? p.x.thread_parts
273
- : [];
357
+ const rawXParts = p.x?.thread_parts;
358
+ const threadParts = Array.isArray(rawXParts) ? rawXParts : [];
274
359
  if (threadParts.length >= 2) {
275
360
  md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
276
361
  for (let i = 0; i < threadParts.length; i++) {
@@ -287,9 +372,8 @@ export function registerPostTools(server, getClient) {
287
372
  }
288
373
  }
289
374
  // Bluesky thread: when present, the canonical post text lives here, not in `content`.
290
- const bskyThreadParts = Array.isArray(p.bluesky?.thread_parts)
291
- ? p.bluesky.thread_parts
292
- : [];
375
+ const rawBskyParts = p.bluesky?.thread_parts;
376
+ const bskyThreadParts = Array.isArray(rawBskyParts) ? rawBskyParts : [];
293
377
  if (bskyThreadParts.length >= 2) {
294
378
  md += `\n\n### Bluesky Thread (${bskyThreadParts.length} parts)\n\n`;
295
379
  for (let i = 0; i < bskyThreadParts.length; i++) {
@@ -306,9 +390,8 @@ export function registerPostTools(server, getClient) {
306
390
  }
307
391
  }
308
392
  // Mastodon thread: when present, the canonical status text lives here, not in `content`.
309
- const mastoThreadParts = Array.isArray(p.mastodon?.thread_parts)
310
- ? p.mastodon.thread_parts
311
- : [];
393
+ const rawMastoParts = p.mastodon?.thread_parts;
394
+ const mastoThreadParts = Array.isArray(rawMastoParts) ? rawMastoParts : [];
312
395
  if (mastoThreadParts.length >= 2) {
313
396
  md += `\n\n### Mastodon Thread (${mastoThreadParts.length} parts)\n\n`;
314
397
  for (let i = 0; i < mastoThreadParts.length; i++) {
@@ -324,6 +407,17 @@ export function registerPostTools(server, getClient) {
324
407
  md += `\n`;
325
408
  }
326
409
  }
410
+ // LinkedIn poll: mutually exclusive with media/link-share, so surface
411
+ // it prominently rather than leaving callers to infer it's a poll.
412
+ if (p.linkedin_poll?.question) {
413
+ const poll = p.linkedin_poll;
414
+ md += `\n\n### LinkedIn Poll\n\n`;
415
+ md += `**${poll.question}**\n\n`;
416
+ for (const option of poll.options || []) {
417
+ md += `- ${option}\n`;
418
+ }
419
+ md += `\n_Duration: ${poll.duration}_\n`;
420
+ }
327
421
  const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
328
422
  ? p.published_urls
329
423
  : {};
@@ -437,6 +531,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
437
531
  - YouTube: Title, privacy status, tags?
438
532
  - TikTok: Privacy level?
439
533
  - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
534
+ - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
440
535
 
441
536
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
442
537
 
@@ -459,22 +554,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
459
554
  link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
460
555
  location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue (with a street address) to tag the post's location. Applied to single-image and carousel Instagram feed posts. To get a valid ID, call the `search_locations` tool with the place name and let the user pick. Ignored by other platforms."),
461
556
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. Invited users get an invite in the Instagram app; once they accept, the post also appears on their profile and feed. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time with a clear error. Ignored by other platforms."),
462
- user_tags: z.array(z.object({
463
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
464
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
465
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
466
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
467
- })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
557
+ user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
468
558
  hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 30-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
469
559
  hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
470
560
  hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
471
- pinterest: z.object({
472
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
473
- title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
474
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
475
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
476
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
477
- }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
561
+ pinterest: PINTEREST_OPTIONS,
478
562
  youtube: z.object({
479
563
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
480
564
  tags: z.array(z.string()).optional(),
@@ -496,66 +580,26 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
496
580
  first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
497
581
  is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
498
582
  trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
583
+ is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
499
584
  }).optional().describe("Instagram options"),
500
585
  facebook: FACEBOOK_OPTIONS,
501
586
  linkedin: LINKEDIN_OPTIONS,
502
587
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
503
- tiktok: z.object({
504
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
505
- disable_comment: z.boolean().optional(),
506
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
507
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
508
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
509
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
510
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
511
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
512
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
513
- }).optional().describe("TikTok options"),
588
+ linkedin_poll: LINKEDIN_POLL_OPTIONS,
589
+ tiktok: TIKTOK_OPTIONS,
514
590
  x: z.object({
515
591
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
516
592
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
517
593
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
518
- thread_parts: z.array(z.object({
519
- text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
520
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
521
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
522
- })).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
594
+ thread_parts: z.array(X_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
523
595
  }).optional().describe("X (Twitter) options"),
524
596
  bluesky: z.object({
525
- thread_parts: z.array(z.object({
526
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
527
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
528
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
529
- })).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
597
+ thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
530
598
  }).optional().describe("Bluesky options"),
531
599
  mastodon: z.object({
532
- thread_parts: z.array(z.object({
533
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
534
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
535
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
536
- })).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
600
+ thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
537
601
  }).optional().describe("Mastodon options"),
538
- google_business: z.object({
539
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
540
- cta: z.object({
541
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]).describe("Button rendered under the caption. Phone numbers belong on a CALL button and links on a LEARN_MORE / BOOK / SHOP button — they're rejected if placed in the caption text."),
542
- url: z.string().optional().describe("Required for every actionType except CALL. Must be http:// or https://. CALL uses the location's phone number from the business profile."),
543
- }).optional().describe("Optional call-to-action button."),
544
- event: z.object({
545
- title: z.string().max(58).describe("Event title (max 58 chars)."),
546
- schedule: z.object({
547
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
548
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
549
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
550
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
551
- }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
552
- }).optional().describe("Required when topic_type is EVENT."),
553
- offer: z.object({
554
- couponCode: z.string().max(58).optional(),
555
- redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
556
- termsConditions: z.string().max(4000).optional(),
557
- }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
558
- }).optional().describe("Google Business Profile options. Use to publish EVENT or OFFER posts, attach a CTA button, or both. Shape mirrors Google's LocalPost resource (see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost).\n\nGoogle Business caption rules (enforced at scheduling — text that violates these will return a `validation_error` 400 before the post is saved):\n • Phone numbers in the caption are rejected — use a CALL button instead.\n • Inline URLs / bare domains / emails are rejected — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons instead.\n • Caption max 1500 characters.\n • Media is optional (text-only posts are allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling — otherwise returns a 400."),
602
+ google_business: GOOGLE_BUSINESS_OPTIONS,
559
603
  }, async (params) => {
560
604
  const result = await getClient().createPost({ ...params, source: "mcp" });
561
605
  if (result.error) {
@@ -564,6 +608,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
564
608
  };
565
609
  }
566
610
  const p = result.data;
611
+ if (!p) {
612
+ // Impossible path (no error + no data) — never surface it as a tool
613
+ // error: agents retry on errors and that produces orphan duplicates.
614
+ return {
615
+ content: [{ type: "text", text: "Draft created, but the API returned no details. Verify with list_posts." }],
616
+ };
617
+ }
567
618
  // The post is already created by the time this runs. If response
568
619
  // formatting throws, we must NOT surface it as a tool error — agents
569
620
  // retry on errors and that produces orphan duplicate posts.
@@ -583,6 +634,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
583
634
  if (appUrl)
584
635
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
585
636
  md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
637
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
638
+ for (const w of result.warnings) {
639
+ md += `⚠️ ${w.message}\n`;
640
+ }
641
+ md += `\n`;
642
+ }
586
643
  md += `_Share the link above with the user so they can review or edit the draft. Publish later with \`publish_post\` once they confirm._`;
587
644
  return { content: [{ type: "text", text: md }] };
588
645
  }
@@ -621,6 +678,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
621
678
  - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** (no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
622
679
  4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
623
680
  5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
681
+ 6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
624
682
 
625
683
  Do NOT call without required media — it will fail.`, {
626
684
  content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
@@ -636,29 +694,23 @@ Do NOT call without required media — it will fail.`, {
636
694
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
637
695
  location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue to tag the post's location. Applied to single-image and carousel Instagram feed posts. Use the `search_locations` tool to find a valid ID. Ignored by other platforms."),
638
696
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms."),
639
- user_tags: z.array(z.object({
640
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
641
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
642
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
643
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
644
- })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
697
+ user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
645
698
  hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. Tags are merged in once at create time; tags already in a caption are skipped."),
646
699
  hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("caption_append (default) appends tags to the captions; first_comment posts them as the auto first comment on comment-capable platforms (others fall back to caption)."),
647
700
  hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
648
- pinterest: z.object({
649
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
650
- title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
651
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
652
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
653
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
654
- }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
701
+ pinterest: PINTEREST_OPTIONS,
655
702
  youtube: z.object({
656
703
  title: z.string().optional().describe("Short title shown on YouTube."),
657
704
  tags: z.array(z.string()).optional(),
658
705
  privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
706
+ category_id: z.string().optional(),
707
+ made_for_kids: z.boolean().optional(),
708
+ notify_subscribers: z.boolean().optional(),
709
+ contains_synthetic_media: z.boolean().optional(),
659
710
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
660
711
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
661
712
  instagram: z.object({
713
+ share_to_feed: z.boolean().optional(),
662
714
  thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
663
715
  thumb_offset: z.number().optional().describe("Reel cover frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
664
716
  cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
@@ -668,66 +720,26 @@ Do NOT call without required media — it will fail.`, {
668
720
  first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
669
721
  is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
670
722
  trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
723
+ is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
671
724
  }).optional().describe("Instagram options"),
672
725
  facebook: FACEBOOK_OPTIONS,
673
726
  linkedin: LINKEDIN_OPTIONS,
674
727
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
675
- tiktok: z.object({
676
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
677
- disable_comment: z.boolean().optional(),
678
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
679
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
680
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
681
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
682
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
683
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
684
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
685
- }).optional().describe("TikTok options"),
728
+ linkedin_poll: LINKEDIN_POLL_OPTIONS,
729
+ tiktok: TIKTOK_OPTIONS,
686
730
  x: z.object({
687
731
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
688
732
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
689
733
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
690
- thread_parts: z.array(z.object({
691
- text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
692
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
693
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
694
- })).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
734
+ thread_parts: z.array(X_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
695
735
  }).optional().describe("X (Twitter) options"),
696
736
  bluesky: z.object({
697
- thread_parts: z.array(z.object({
698
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
699
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
700
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
701
- })).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
737
+ thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
702
738
  }).optional().describe("Bluesky options"),
703
739
  mastodon: z.object({
704
- thread_parts: z.array(z.object({
705
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
706
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
707
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
708
- })).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
740
+ thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
709
741
  }).optional().describe("Mastodon options"),
710
- google_business: z.object({
711
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
712
- cta: z.object({
713
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
714
- url: z.string().optional(),
715
- }).optional(),
716
- event: z.object({
717
- title: z.string().max(58),
718
- schedule: z.object({
719
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
720
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
721
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
722
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
723
- }).optional(),
724
- }).optional(),
725
- offer: z.object({
726
- couponCode: z.string().max(58).optional(),
727
- redeemOnlineUrl: z.string().optional(),
728
- termsConditions: z.string().max(4000).optional(),
729
- }).optional(),
730
- }).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
742
+ google_business: GOOGLE_BUSINESS_OPTIONS,
731
743
  }, async (params) => {
732
744
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
733
745
  if (result.error) {
@@ -736,6 +748,13 @@ Do NOT call without required media — it will fail.`, {
736
748
  };
737
749
  }
738
750
  const p = result.data;
751
+ if (!p) {
752
+ // Impossible path (no error + no data) — the post is already queued;
753
+ // never let it look like an API error (agents would retry = repost).
754
+ return {
755
+ content: [{ type: "text", text: "Post created and queued for publishing, but the API returned no details. Verify with list_posts." }],
756
+ };
757
+ }
739
758
  const appUrl = postAppUrl(p);
740
759
  try {
741
760
  const channels = selectedChannels(p);
@@ -749,6 +768,12 @@ Do NOT call without required media — it will fail.`, {
749
768
  if (appUrl)
750
769
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
751
770
  md += `\n**Content:** ${truncate(p.content, 100)}`;
771
+ if (Array.isArray(result.warnings) && result.warnings.length > 0) {
772
+ md += `\n`;
773
+ for (const w of result.warnings) {
774
+ md += `\n⚠️ ${w.message}`;
775
+ }
776
+ }
752
777
  return { content: [{ type: "text", text: md }] };
753
778
  }
754
779
  catch {
@@ -778,12 +803,7 @@ Do NOT call without required media — it will fail.`, {
778
803
  ]).superRefine(pinterestImageCap).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
779
804
  location_id: z.string().optional().describe("Instagram only. Facebook Place/Page ID to tag the post's location with. Send an empty string to clear an existing location tag. Ignored by other platforms."),
780
805
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors. Replaces the existing collaborator list. Send an empty array to clear collaborators. Works on image, carousel, and reel posts — NOT Stories. Ignored by other platforms."),
781
- user_tags: z.array(z.object({
782
- username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
783
- x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
784
- y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
785
- image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
786
- })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
806
+ user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
787
807
  youtube: z.object({
788
808
  title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
789
809
  tags: z.array(z.string()).optional(),
@@ -794,13 +814,7 @@ Do NOT call without required media — it will fail.`, {
794
814
  contains_synthetic_media: z.boolean().optional(),
795
815
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled. Pass an empty string to clear a previously set first comment."),
796
816
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
797
- pinterest: z.object({
798
- board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
799
- title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
800
- link: z.string().optional().describe("Destination URL the pin clicks through to"),
801
- video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
802
- alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
803
- }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
817
+ pinterest: PINTEREST_OPTIONS,
804
818
  instagram: z.object({
805
819
  share_to_feed: z.boolean().optional(),
806
820
  thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
@@ -812,66 +826,26 @@ Do NOT call without required media — it will fail.`, {
812
826
  first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Pass an empty string to clear a previously set first comment."),
813
827
  is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account. Ineligible accounts fail at publish time with a clear per-platform error. Reels only. Pass false to turn a Trial Reel back into a regular reel."),
814
828
  trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
829
+ is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
815
830
  }).optional().describe("Instagram options. MERGED into the post's stored options: omitted keys keep their current value (updating one option cannot clear a Trial Reel flag or reel music configured elsewhere)."),
816
831
  facebook: FACEBOOK_OPTIONS,
817
832
  linkedin: LINKEDIN_OPTIONS,
818
833
  linkedin_page: LINKEDIN_PAGE_OPTIONS,
819
- tiktok: z.object({
820
- privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
821
- disable_comment: z.boolean().optional(),
822
- disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
823
- disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
824
- video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
825
- is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
826
- brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
827
- brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
828
- auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
829
- }).optional().describe("TikTok options"),
834
+ linkedin_poll: LINKEDIN_POLL_OPTIONS,
835
+ tiktok: TIKTOK_OPTIONS,
830
836
  x: z.object({
831
837
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
832
838
  paid_partnership: z.boolean().optional(),
833
839
  made_with_ai: z.boolean().optional(),
834
- thread_parts: z.array(z.object({
835
- text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
836
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
837
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
838
- })).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
840
+ thread_parts: z.array(X_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
839
841
  }).optional().describe("X (Twitter) options"),
840
842
  bluesky: z.object({
841
- thread_parts: z.array(z.object({
842
- text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
843
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
844
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
845
- })).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
843
+ thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
846
844
  }).optional().describe("Bluesky options"),
847
845
  mastodon: z.object({
848
- thread_parts: z.array(z.object({
849
- text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
850
- media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
851
- media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
852
- })).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
846
+ thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
853
847
  }).optional().describe("Mastodon options"),
854
- google_business: z.object({
855
- topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
856
- cta: z.object({
857
- actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
858
- url: z.string().optional(),
859
- }).optional(),
860
- event: z.object({
861
- title: z.string().max(58),
862
- schedule: z.object({
863
- startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
864
- startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
865
- endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
866
- endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
867
- }).optional(),
868
- }).optional(),
869
- offer: z.object({
870
- couponCode: z.string().max(58).optional(),
871
- redeemOnlineUrl: z.string().optional(),
872
- termsConditions: z.string().max(4000).optional(),
873
- }).optional(),
874
- }).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
848
+ google_business: GOOGLE_BUSINESS_OPTIONS,
875
849
  }, async ({ id, ...data }) => {
876
850
  const result = await getClient().updatePost(id, data);
877
851
  if (result.error) {
@@ -880,6 +854,11 @@ Do NOT call without required media — it will fail.`, {
880
854
  };
881
855
  }
882
856
  const p = result.data;
857
+ if (!p) {
858
+ return {
859
+ content: [{ type: "text", text: "Post updated, but the API returned no details. Verify with get_post." }],
860
+ };
861
+ }
883
862
  const schedAt = p.schedule_at ?? p.scheduled_at;
884
863
  const appUrl = postAppUrl(p);
885
864
  let md = `## Post Updated\n\n`;
@@ -915,6 +894,11 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
915
894
  };
916
895
  }
917
896
  const p = result.data;
897
+ if (!p) {
898
+ return {
899
+ content: [{ type: "text", text: "Post queued for publishing. Check its status with get_post." }],
900
+ };
901
+ }
918
902
  let md = `## Post Queued for Publishing\n\n`;
919
903
  md += `| Field | Value |\n`;
920
904
  md += `|-------|-------|\n`;
@@ -936,6 +920,11 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
936
920
  };
937
921
  }
938
922
  const p = result.data;
923
+ if (!p) {
924
+ return {
925
+ content: [{ type: "text", text: "Retry queued. Check the outcome with get_post in a minute or two." }],
926
+ };
927
+ }
939
928
  let md = `## Retry Queued\n\n`;
940
929
  md += `| Field | Value |\n`;
941
930
  md += `|-------|-------|\n`;
@@ -964,7 +953,10 @@ Notes:
964
953
  const result = await getClient().searchLocations(query);
965
954
  const places = result?.data || [];
966
955
  if (!places.length) {
967
- const why = result?.error ||
956
+ // The degraded path returns `error` as a plain string; the object form
957
+ // only appears on transport failures — render its message.
958
+ const err = result?.error;
959
+ const why = (typeof err === "string" ? err : err?.message) ||
968
960
  `No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
969
961
  return { content: [{ type: "text", text: why }] };
970
962
  }
@@ -992,7 +984,10 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
992
984
  const result = await getClient().searchInstagramAudio(query, type);
993
985
  const tracks = result?.data || [];
994
986
  if (!tracks.length) {
995
- const why = result?.error ||
987
+ // Same nonstandard envelope as search_locations — string `error` on
988
+ // the degraded path, { code, message } only on transport failures.
989
+ const err = result?.error;
990
+ const why = (typeof err === "string" ? err : err?.message) ||
996
991
  (query
997
992
  ? `No tracks found for "${query}". Try another title or artist — only music licensed for third-party publishing appears here.`
998
993
  : "No trending audio available right now.");
@@ -1011,11 +1006,11 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1011
1006
  });
1012
1007
  return { content: [{ type: "text", text: md }] };
1013
1008
  });
1014
- server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
1009
+ server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live for most platforms, so expect a few seconds of latency; X results may come from a snapshot up to 24h old (X bills per returned post) — the snapshot refreshes right after the user publishes to X through OmniSocials. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
1015
1010
  limit: z
1016
1011
  .string()
1017
1012
  .optional()
1018
- .describe("Max posts per connected platform (1-50, default 25)."),
1013
+ .describe("Max posts per connected platform (1-50, default 25; X defaults to 10 unless set explicitly — its API bills per returned post)."),
1019
1014
  platforms: z
1020
1015
  .string()
1021
1016
  .optional()