@omnisocials/mcp-server 1.3.8 → 1.3.9

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/build/client.d.ts CHANGED
@@ -49,11 +49,16 @@ export declare class OmniSocialsClient {
49
49
  media_urls?: string[] | Record<string, string[]>;
50
50
  type?: string;
51
51
  source?: string;
52
+ link_url?: string;
53
+ link_title?: string;
54
+ link_description?: string;
55
+ link_thumbnail_url?: string;
52
56
  pinterest?: Record<string, unknown>;
53
57
  youtube?: Record<string, unknown>;
54
58
  instagram?: Record<string, unknown>;
55
59
  tiktok?: Record<string, unknown>;
56
60
  x?: XPostOptions;
61
+ google_business?: Record<string, unknown>;
57
62
  }): Promise<ApiResponse<unknown>>;
58
63
  createAndPublishPost(data: {
59
64
  content: string | Record<string, string>;
@@ -62,11 +67,16 @@ export declare class OmniSocialsClient {
62
67
  media_urls?: string[] | Record<string, string[]>;
63
68
  type?: string;
64
69
  source?: string;
70
+ link_url?: string;
71
+ link_title?: string;
72
+ link_description?: string;
73
+ link_thumbnail_url?: string;
65
74
  pinterest?: Record<string, unknown>;
66
75
  youtube?: Record<string, unknown>;
67
76
  instagram?: Record<string, unknown>;
68
77
  tiktok?: Record<string, unknown>;
69
78
  x?: XPostOptions;
79
+ google_business?: Record<string, unknown>;
70
80
  }): Promise<ApiResponse<unknown>>;
71
81
  updatePost(id: string, data: {
72
82
  content?: string | Record<string, string>;
@@ -80,6 +90,7 @@ export declare class OmniSocialsClient {
80
90
  instagram?: Record<string, unknown>;
81
91
  tiktok?: Record<string, unknown>;
82
92
  x?: XPostOptionsUpdate;
93
+ google_business?: Record<string, unknown>;
83
94
  }): Promise<ApiResponse<unknown>>;
84
95
  deletePost(id: string): Promise<ApiResponse<unknown>>;
85
96
  publishPost(id: string): Promise<ApiResponse<unknown>>;
@@ -5,7 +5,7 @@ export function registerAccountTools(server, getClient) {
5
5
  const result = await getClient().listAccounts();
6
6
  if (result.error) {
7
7
  return {
8
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
8
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
9
9
  };
10
10
  }
11
11
  const accounts = Array.isArray(result.data) ? result.data : result.data?.accounts || [];
@@ -23,8 +23,9 @@ export function registerAccountTools(server, getClient) {
23
23
  const name = a.username ? `@${a.username}` : a.display_name || "—";
24
24
  const types = (a.content_types || []).join(", ");
25
25
  const platform = capitalize(a.platform);
26
- const sub = a.platform_details?.subscription_type && a.platform_details.subscription_type !== "None"
27
- ? ` (${a.platform_details.subscription_type})`
26
+ const subType = a.platform_details?.subscription_type;
27
+ const sub = subType === "Premium" || subType === "PremiumPlus"
28
+ ? ` (${subType})`
28
29
  : "";
29
30
  const statusCell = a.needs_reconnect
30
31
  ? `⚠️ needs_reconnect`
@@ -66,7 +67,7 @@ export function registerAccountTools(server, getClient) {
66
67
  const result = await getClient().getAccount(id);
67
68
  if (result.error) {
68
69
  return {
69
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
70
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
70
71
  };
71
72
  }
72
73
  const a = result.data;
@@ -88,8 +89,9 @@ export function registerAccountTools(server, getClient) {
88
89
  md += `| **Reconnect reason** | ${a.reauth_reason} |\n`;
89
90
  }
90
91
  md += `| **Connected** | ${a.connected_at ? new Date(a.connected_at).toLocaleDateString() : "—"} |\n`;
91
- if (a.platform_details?.subscription_type) {
92
- md += `| **Subscription** | ${a.platform_details.subscription_type} |\n`;
92
+ const getSubType = a.platform_details?.subscription_type;
93
+ if (getSubType === "Premium" || getSubType === "PremiumPlus") {
94
+ md += `| **Subscription** | ${getSubType} |\n`;
93
95
  }
94
96
  if (a.boards?.length) {
95
97
  md += `\n### Pinterest Boards\n\n`;
@@ -7,7 +7,7 @@ export function registerAnalyticsTools(server, getClient) {
7
7
  const result = await getClient().getPostAnalytics(post_id);
8
8
  if (result.error) {
9
9
  return {
10
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
10
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
11
11
  };
12
12
  }
13
13
  const d = result.data;
@@ -73,7 +73,7 @@ export function registerAnalyticsTools(server, getClient) {
73
73
  const result = await getClient().getAnalyticsOverview(params);
74
74
  if (result.error) {
75
75
  return {
76
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
76
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
77
77
  };
78
78
  }
79
79
  const d = result.data;
@@ -118,7 +118,7 @@ export function registerAnalyticsTools(server, getClient) {
118
118
  const result = await getClient().getAccountAnalytics(params);
119
119
  if (result.error) {
120
120
  return {
121
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
121
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
122
122
  };
123
123
  }
124
124
  const accounts = result.data || [];
@@ -8,7 +8,7 @@ export function registerMediaTools(server, getClient) {
8
8
  const result = await getClient().listMedia(params);
9
9
  if (result.error) {
10
10
  return {
11
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
11
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
12
12
  };
13
13
  }
14
14
  const items = Array.isArray(result.data) ? result.data : result.data?.media || [];
@@ -75,7 +75,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
75
75
  }
76
76
  if (result.error) {
77
77
  return {
78
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
78
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
79
79
  };
80
80
  }
81
81
  const m = result.data;
@@ -96,7 +96,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
96
96
  }, async ({ id }) => {
97
97
  const result = await getClient().deleteMedia(id);
98
98
  return {
99
- content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Media deleted successfully." }],
99
+ content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Media deleted successfully." }],
100
100
  };
101
101
  });
102
102
  }
@@ -61,7 +61,7 @@ export function registerPostTools(server, getClient) {
61
61
  const result = await getClient().listPosts(params);
62
62
  if (result.error) {
63
63
  return {
64
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
64
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
65
65
  };
66
66
  }
67
67
  const posts = Array.isArray(result.data) ? result.data : result.data?.posts || [];
@@ -106,7 +106,7 @@ export function registerPostTools(server, getClient) {
106
106
  const result = await getClient().getPost(id);
107
107
  if (result.error) {
108
108
  return {
109
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
109
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
110
110
  };
111
111
  }
112
112
  const p = result.data;
@@ -185,6 +185,21 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
185
185
  - TikTok posts: ALWAYS require at least one image or video.
186
186
  - Pinterest posts: ALWAYS require an image AND a board_id.
187
187
  - Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
188
+
189
+ **Per-platform max media items (enforced at submit, returns 400 validation_error if exceeded):**
190
+ - Bluesky, X, Mastodon — max **4** items per post
191
+ - Instagram, Threads — max **10** items per carousel
192
+ - TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
193
+ - Pinterest carousel — 2–5 images, all same aspect ratio
194
+
195
+ **Per-platform video file-size caps (validated via ffprobe at schedule/publish time; drafts exempt):**
196
+ Mastodon **99 MB** · Bluesky **100 MB** · Instagram **300 MB** · X **512 MB** (free tier) · Threads / Reddit **1 GB** · Pinterest **2 GB** · Facebook / TikTok **4 GB** · LinkedIn **5 GB** · YouTube **256 GB**. Uploads themselves are capped at 1 GB on top.
197
+
198
+ **Per-platform video duration caps (validated via ffprobe at schedule/publish time; drafts exempt):**
199
+ Facebook Reel **90 s** · YouTube Short **3 min** · X **140 s** · Bluesky **180 s** · Threads **5 min** · TikTok **10 min** · LinkedIn **10 min** · Instagram Reel **15 min** · Pinterest **15 min** · Reddit **15 min** · Facebook Post **240 min** · Mastodon (instance-dependent).
200
+ When a cap is exceeded, the API returns \`400 { code: "validation_error", message: "<Platform> only allows videos up to <cap> — yours is <duration>. Trim the video or deselect <Platform>." }\`. If you know the video is over a platform's cap before sending, warn the user and either trim, deselect that platform, or skip publishing to it.
201
+
202
+ When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
188
203
  5. **Platform-specific options** (ask only when relevant):
189
204
  - **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking the user — and do NOT skip Pinterest. Instead:
190
205
  1. Call \`get_account\` on the Pinterest account ID — the response includes a \`Pinterest Boards\` table with each board's name and ID.
@@ -210,13 +225,17 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
210
225
  z.record(z.string(), z.array(z.string())),
211
226
  ]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Max 10 total. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media."),
212
227
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
228
+ link_url: z.string().optional().describe("URL to share as a rich preview card on platforms that support link-share posts (LinkedIn and Facebook). The URL renders as a tile with thumbnail / title / description instead of plain text. Ignored on platforms that don't support link shares, and ignored on posts that already have media attached (media wins)."),
229
+ link_title: z.string().optional().describe("Optional title for the link-share preview. LinkedIn uses this when set; Facebook ignores it and fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."),
230
+ link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
231
+ 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)."),
213
232
  pinterest: z.object({
214
233
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
215
- title: z.string().optional().describe("Pin title (max 100 characters)"),
234
+ 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."),
216
235
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
217
236
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
218
237
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
219
- }).optional().describe("Pinterest-specific options"),
238
+ }).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. 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."),
220
239
  youtube: z.object({
221
240
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
222
241
  tags: z.array(z.string()).optional(),
@@ -249,11 +268,32 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
249
268
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
250
269
  })).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. Per-part media_urls override top-level media for that specific tweet. For a single tweet, omit thread_parts and use content."),
251
270
  }).optional().describe("X (Twitter) options"),
271
+ google_business: z.object({
272
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
273
+ cta: z.object({
274
+ 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."),
275
+ 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."),
276
+ }).optional().describe("Optional call-to-action button."),
277
+ event: z.object({
278
+ title: z.string().max(58).describe("Event title (max 58 chars)."),
279
+ schedule: z.object({
280
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
281
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
282
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
283
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
284
+ }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
285
+ }).optional().describe("Required when topic_type is EVENT."),
286
+ offer: z.object({
287
+ couponCode: z.string().max(58).optional(),
288
+ redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
289
+ termsConditions: z.string().max(4000).optional(),
290
+ }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
291
+ }).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."),
252
292
  }, async (params) => {
253
293
  const result = await getClient().createPost({ ...params, source: "mcp" });
254
294
  if (result.error) {
255
295
  return {
256
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
296
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
257
297
  };
258
298
  }
259
299
  const p = result.data;
@@ -310,6 +350,9 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
310
350
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
311
351
  - Pinterest: ALWAYS need an image + board_id.
312
352
  - Ask the user which media to use. Use list_media to show options.
353
+ - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos with no photo/video mix, Pinterest carousel 2–5 same-ratio images.
354
+ - **Video duration caps** (same as create_post): FB Reel **90 s** · YouTube Short **3 min** · X 140 s · Bluesky 180 s · Threads 5 min · TikTok 10 min · IG Reel 15 min. Returns 400 with "<Platform> only allows videos up to <cap> — yours is <duration>" when exceeded.
355
+ - **Video size caps** (same as create_post): Mastodon 99 MB · Bluesky 100 MB · IG 300 MB · X 512 MB · Threads/Reddit 1 GB · Pinterest 2 GB · Facebook/TikTok 4 GB · LinkedIn 5 GB. Returns 400 with "This video (<size>) is too large for <Platform>" when exceeded.
313
356
  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.
314
357
  5. **X threads**: When the user asks for an X/Twitter "thread" (or anything > 280 chars on X), pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
315
358
 
@@ -327,11 +370,11 @@ Do NOT call without required media — it will fail.`, {
327
370
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
328
371
  pinterest: z.object({
329
372
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
330
- title: z.string().optional().describe("Pin title (max 100 characters)"),
373
+ 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."),
331
374
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
332
375
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
333
376
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
334
- }).optional().describe("Pinterest-specific options"),
377
+ }).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. 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."),
335
378
  youtube: z.object({
336
379
  title: z.string().optional().describe("Short title shown on YouTube."),
337
380
  tags: z.array(z.string()).optional(),
@@ -349,11 +392,32 @@ Do NOT call without required media — it will fail.`, {
349
392
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
350
393
  })).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. Per-part media_urls override top-level media."),
351
394
  }).optional().describe("X (Twitter) options"),
395
+ google_business: z.object({
396
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
397
+ cta: z.object({
398
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
399
+ url: z.string().optional(),
400
+ }).optional(),
401
+ event: z.object({
402
+ title: z.string().max(58),
403
+ schedule: z.object({
404
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
405
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
406
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
407
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
408
+ }).optional(),
409
+ }).optional(),
410
+ offer: z.object({
411
+ couponCode: z.string().max(58).optional(),
412
+ redeemOnlineUrl: z.string().optional(),
413
+ termsConditions: z.string().max(4000).optional(),
414
+ }).optional(),
415
+ }).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."),
352
416
  }, async (params) => {
353
417
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
354
418
  if (result.error) {
355
419
  return {
356
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
420
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
357
421
  };
358
422
  }
359
423
  const p = result.data;
@@ -408,11 +472,11 @@ Do NOT call without required media — it will fail.`, {
408
472
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
409
473
  pinterest: z.object({
410
474
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
411
- title: z.string().optional().describe("Pin title (max 100 characters)"),
475
+ 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."),
412
476
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
413
477
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
414
478
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
415
- }).optional().describe("Pinterest-specific options"),
479
+ }).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. 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."),
416
480
  instagram: z.object({
417
481
  share_to_feed: z.boolean().optional(),
418
482
  thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
@@ -436,11 +500,32 @@ Do NOT call without required media — it will fail.`, {
436
500
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
437
501
  })).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, or `null` to revert to single-tweet mode."),
438
502
  }).optional().describe("X (Twitter) options"),
503
+ google_business: z.object({
504
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
505
+ cta: z.object({
506
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
507
+ url: z.string().optional(),
508
+ }).optional(),
509
+ event: z.object({
510
+ title: z.string().max(58),
511
+ schedule: z.object({
512
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
513
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
514
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
515
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
516
+ }).optional(),
517
+ }).optional(),
518
+ offer: z.object({
519
+ couponCode: z.string().max(58).optional(),
520
+ redeemOnlineUrl: z.string().optional(),
521
+ termsConditions: z.string().max(4000).optional(),
522
+ }).optional(),
523
+ }).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."),
439
524
  }, async ({ id, ...data }) => {
440
525
  const result = await getClient().updatePost(id, data);
441
526
  if (result.error) {
442
527
  return {
443
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
528
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
444
529
  };
445
530
  }
446
531
  const p = result.data;
@@ -464,7 +549,7 @@ Do NOT call without required media — it will fail.`, {
464
549
  }, async ({ id }) => {
465
550
  const result = await getClient().deletePost(id);
466
551
  return {
467
- content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Post deleted successfully." }],
552
+ content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
468
553
  };
469
554
  });
470
555
  server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing.
@@ -475,7 +560,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
475
560
  const result = await getClient().publishPost(id);
476
561
  if (result.error) {
477
562
  return {
478
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
563
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
479
564
  };
480
565
  }
481
566
  const p = result.data;
@@ -5,7 +5,7 @@ export function registerWebhookTools(server, getClient) {
5
5
  const result = await getClient().listWebhooks();
6
6
  if (result.error) {
7
7
  return {
8
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
8
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
9
9
  };
10
10
  }
11
11
  const webhooks = Array.isArray(result.data) ? result.data : result.data?.webhooks || [];
@@ -35,7 +35,7 @@ export function registerWebhookTools(server, getClient) {
35
35
  const result = await getClient().createWebhook(params);
36
36
  if (result.error) {
37
37
  return {
38
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
38
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
39
39
  };
40
40
  }
41
41
  const w = result.data;
@@ -60,7 +60,7 @@ export function registerWebhookTools(server, getClient) {
60
60
  const result = await getClient().getWebhook(id);
61
61
  if (result.error) {
62
62
  return {
63
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
63
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
64
64
  };
65
65
  }
66
66
  const w = result.data;
@@ -87,7 +87,7 @@ export function registerWebhookTools(server, getClient) {
87
87
  const result = await getClient().updateWebhook(id, data);
88
88
  if (result.error) {
89
89
  return {
90
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
90
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
91
91
  };
92
92
  }
93
93
  const w = result.data;
@@ -107,7 +107,7 @@ export function registerWebhookTools(server, getClient) {
107
107
  }, async ({ id }) => {
108
108
  const result = await getClient().deleteWebhook(id);
109
109
  return {
110
- content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Webhook deleted successfully." }],
110
+ content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Webhook deleted successfully." }],
111
111
  };
112
112
  });
113
113
  server.tool("rotate_webhook_secret", "Rotate the signing secret for a webhook. The new secret will only be shown once.", {
@@ -116,7 +116,7 @@ export function registerWebhookTools(server, getClient) {
116
116
  const result = await getClient().rotateWebhookSecret(id);
117
117
  if (result.error) {
118
118
  return {
119
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
119
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
120
120
  };
121
121
  }
122
122
  const w = result.data;
package/build/types.d.ts CHANGED
@@ -14,6 +14,14 @@ export interface Post {
14
14
  channels: string[];
15
15
  media: string[];
16
16
  created_at: string;
17
+ /**
18
+ * Per-platform user-friendly error messages, keyed by platform identifier
19
+ * (facebook, instagram, linkedin, linkedin_page, youtube, tiktok, pinterest,
20
+ * x, threads, bluesky, mastodon, google_business). Populated when `status`
21
+ * is `failed` or `warning`. Only failed platforms appear. `null` while the
22
+ * post is still draft/scheduled/processing or every platform succeeded.
23
+ */
24
+ errors?: Record<string, string> | null;
17
25
  x?: {
18
26
  reply_settings?: string;
19
27
  paid_partnership?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.3.8",
3
+ "version": "1.3.9",
4
4
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
5
5
  "type": "module",
6
6
  "main": "build/index.js",