@omnisocials/mcp-server 1.3.7 → 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/README.md CHANGED
@@ -191,6 +191,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
191
191
 
192
192
  ## Changelog
193
193
 
194
+ ### 1.3.8
195
+
196
+ - **Fixed:** Pinterest pins created via `create_post` / `create_and_publish_post` / `update_post` now publish with title, link, video cover, and alt text. Companion server fix — the API was storing those fields under unprefixed keys while the publisher read them under `pinterest_*` prefixes, so pins published with no metadata even when the agent passed it. Existing clients on 1.3.7 benefit automatically once the backend deploys.
197
+ - **Added:** `pinterest.video_cover` and `pinterest.alt_text` to the tool schemas. `alt_text` improves accessibility + Pinterest's discoverability for visually impaired users; `video_cover` sets a custom cover image for video pins (otherwise Pinterest uses a 1s video keyframe).
198
+
194
199
  ### 1.3.7
195
200
 
196
201
  - **Fixed:** `update_post` now accepts per-platform options (`youtube`, `pinterest`, `instagram`, `tiktok`) — same shape as `create_post`. Previously these were missing from the `update_post` schema, so agents that tried to rename a scheduled YouTube Short via `youtube.title` had the field silently stripped before it reached the server. Renaming a Short, changing privacy, adjusting a Pinterest board, etc. on an existing draft now work.
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>>;
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { fetchImageAsBase64, capitalize } from "../client.js";
3
3
  export function registerAccountTools(server, getClient) {
4
- server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), and supported content_types (post, story, reel). Pinterest accounts also include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
4
+ server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
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 || [];
@@ -16,17 +16,27 @@ export function registerAccountTools(server, getClient) {
16
16
  }
17
17
  // Build markdown table
18
18
  let md = `## Connected Accounts (${accounts.length})\n\n`;
19
- md += `| # | Platform | Account | Content Types | Channel ID |\n`;
20
- md += `|---|----------|---------|---------------|------------|\n`;
19
+ md += `| # | Platform | Account | Content Types | Channel ID | Status |\n`;
20
+ md += `|---|----------|---------|---------------|------------|--------|\n`;
21
21
  for (let i = 0; i < accounts.length; i++) {
22
22
  const a = accounts[i];
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
- md += `| ${i + 1} | ${platform}${sub} | ${name} | ${types} | \`${a.id}\` |\n`;
30
+ const statusCell = a.needs_reconnect
31
+ ? `⚠️ needs_reconnect`
32
+ : "active";
33
+ md += `| ${i + 1} | ${platform}${sub} | ${name} | ${types} | \`${a.id}\` | ${statusCell} |\n`;
34
+ }
35
+ const reconnects = accounts.filter((a) => a.needs_reconnect);
36
+ if (reconnects.length) {
37
+ md += `\n⚠️ ${reconnects.length} account(s) need reconnecting before posting will succeed: ${reconnects
38
+ .map((a) => capitalize(a.platform))
39
+ .join(", ")}. Ask the user to reconnect in OmniSocials (Settings → Organisation → Workspaces).\n`;
30
40
  }
31
41
  md += `\nUse the **Channel ID** as the \`channels\` parameter when creating posts.`;
32
42
  // Fetch profile pictures
@@ -57,7 +67,7 @@ export function registerAccountTools(server, getClient) {
57
67
  const result = await getClient().getAccount(id);
58
68
  if (result.error) {
59
69
  return {
60
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
70
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
61
71
  };
62
72
  }
63
73
  const a = result.data;
@@ -74,10 +84,14 @@ export function registerAccountTools(server, getClient) {
74
84
  md += `| **Username** | ${a.username ? `@${a.username}` : "—"} |\n`;
75
85
  md += `| **Display Name** | ${a.display_name || "—"} |\n`;
76
86
  md += `| **Content Types** | ${(a.content_types || []).join(", ")} |\n`;
77
- md += `| **Status** | ${a.status} |\n`;
87
+ md += `| **Status** | ${a.needs_reconnect ? "⚠️ needs_reconnect" : a.status} |\n`;
88
+ if (a.needs_reconnect && a.reauth_reason) {
89
+ md += `| **Reconnect reason** | ${a.reauth_reason} |\n`;
90
+ }
78
91
  md += `| **Connected** | ${a.connected_at ? new Date(a.connected_at).toLocaleDateString() : "—"} |\n`;
79
- if (a.platform_details?.subscription_type) {
80
- 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`;
81
95
  }
82
96
  if (a.boards?.length) {
83
97
  md += `\n### Pinterest Boards\n\n`;
@@ -7,62 +7,85 @@ 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;
14
- if (!d) {
14
+ // API returns { post_id, platforms: { <platform>: { metrics: {...} } } }.
15
+ // Aggregate the per-platform metrics into totals; the legacy flat shape
16
+ // (d.impressions / d.platform_stats) doesn't exist on this endpoint and
17
+ // reading those keys returned "—" for every field.
18
+ const platforms = d?.platforms || {};
19
+ const platformEntries = Object.entries(platforms);
20
+ if (platformEntries.length === 0) {
15
21
  return {
16
- content: [{ type: "text", text: "No analytics found for this post." }],
22
+ content: [{
23
+ type: "text",
24
+ text: "No analytics collected yet for this post. Stats are fetched periodically after publishing; check back in a few hours, or verify the post finished publishing successfully.",
25
+ }],
17
26
  };
18
27
  }
28
+ const totals = { impressions: 0, engagements: 0, likes: 0, comments: 0, shares: 0 };
29
+ const perPlatform = [];
30
+ for (const [platform, entry] of platformEntries) {
31
+ const m = entry?.metrics || {};
32
+ const impressions = platform === "instagram"
33
+ ? Number(m.reach ?? m.impressions ?? 0)
34
+ : Number(m.views ?? m.impressions ?? 0);
35
+ const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
36
+ const comments = Number(m.comments ?? m.replies ?? 0);
37
+ const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
38
+ const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
39
+ totals.impressions += impressions;
40
+ totals.engagements += engagements;
41
+ totals.likes += likes;
42
+ totals.comments += comments;
43
+ totals.shares += shares;
44
+ perPlatform.push({ platform, impressions, engagements, likes, comments, shares });
45
+ }
19
46
  let md = `## Post Analytics\n\n`;
20
47
  md += `| Metric | Value |\n`;
21
48
  md += `|--------|-------|\n`;
22
- md += `| **Impressions** | ${formatNumber(d.impressions ?? 0)} |\n`;
23
- md += `| **Engagements** | ${formatNumber(d.engagements ?? 0)} |\n`;
24
- md += `| **Likes** | ${formatNumber(d.likes ?? 0)} |\n`;
25
- md += `| **Comments** | ${formatNumber(d.comments ?? 0)} |\n`;
26
- md += `| **Shares** | ${formatNumber(d.shares ?? 0)} |\n`;
27
- if (d.impressions > 0) {
28
- const rate = ((d.engagements ?? 0) / d.impressions * 100).toFixed(2);
49
+ md += `| **Impressions** | ${formatNumber(totals.impressions)} |\n`;
50
+ md += `| **Engagements** | ${formatNumber(totals.engagements)} |\n`;
51
+ md += `| **Likes** | ${formatNumber(totals.likes)} |\n`;
52
+ md += `| **Comments** | ${formatNumber(totals.comments)} |\n`;
53
+ md += `| **Shares** | ${formatNumber(totals.shares)} |\n`;
54
+ if (totals.impressions > 0) {
55
+ const rate = ((totals.engagements / totals.impressions) * 100).toFixed(2);
29
56
  md += `| **Engagement Rate** | ${rate}% |\n`;
30
57
  }
31
- if (d.platform_stats && Object.keys(d.platform_stats).length > 0) {
32
- md += `\n### Per-Platform Breakdown\n\n`;
33
- for (const [platform, stats] of Object.entries(d.platform_stats)) {
34
- const s = stats;
35
- md += `**${capitalize(platform)}:** `;
36
- const parts = [];
37
- if (s.impressions !== undefined)
38
- parts.push(`${formatNumber(s.impressions)} impressions`);
39
- if (s.engagements !== undefined)
40
- parts.push(`${formatNumber(s.engagements)} engagements`);
41
- if (s.likes !== undefined)
42
- parts.push(`${formatNumber(s.likes)} likes`);
43
- if (s.comments !== undefined)
44
- parts.push(`${formatNumber(s.comments)} comments`);
45
- md += parts.join(", ") + "\n\n";
46
- }
58
+ md += `\n### Per-Platform Breakdown\n\n`;
59
+ md += `| Platform | Impressions | Engagements | Likes | Comments | Shares |\n`;
60
+ md += `|----------|-------------|-------------|-------|----------|--------|\n`;
61
+ for (const p of perPlatform) {
62
+ md += `| ${capitalize(p.platform)} | ${formatNumber(p.impressions)} | ${formatNumber(p.engagements)} | ${formatNumber(p.likes)} | ${formatNumber(p.comments)} | ${formatNumber(p.shares)} |\n`;
47
63
  }
48
64
  return {
49
65
  content: [{ type: "text", text: md }],
50
66
  };
51
67
  });
52
- server.tool("get_analytics_overview", "Get an overview of analytics across all posts and accounts for a time period.", {
53
- period: z.string().optional().describe("Time period: 7d, 30d, 90d (default: 30d)"),
54
- start_date: z.string().optional().describe("Custom start date (ISO 8601)"),
55
- end_date: z.string().optional().describe("Custom end date (ISO 8601)"),
68
+ server.tool("get_analytics_overview", "Get analytics overview with total posts, impressions, engagements, engagement rate, and per-platform breakdown. Use `period` for a rolling window (7d / 30d / 90d) or `start_date` + `end_date` for a custom range. The response echoes the resolved range and today's date — read those before assuming a year from training data (e.g. when the user says 'April', use the most recent April, not April from your training cutoff).", {
69
+ period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
70
+ start_date: z.string().optional().describe('Custom start date. Accepts "YYYY-MM-DD" (e.g. "2026-04-01") or "YYYY-MM" month-shorthand (e.g. "2026-04" → first of the month).'),
71
+ end_date: z.string().optional().describe('Custom end date. Same formats as start_date; "YYYY-MM" expands to the last day of the month.'),
56
72
  }, async (params) => {
57
73
  const result = await getClient().getAnalyticsOverview(params);
58
74
  if (result.error) {
59
75
  return {
60
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
76
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
61
77
  };
62
78
  }
63
79
  const d = result.data;
64
- const period = result.period || params.period || "30d";
65
- let md = `## Analytics Overview (Last ${period})\n\n`;
80
+ const resolvedStart = result.start_date;
81
+ const resolvedEnd = result.end_date;
82
+ const currentDate = result.current_date;
83
+ const periodLabel = resolvedStart && resolvedEnd
84
+ ? `${resolvedStart} → ${resolvedEnd}`
85
+ : result.period || params.period || "30d";
86
+ let md = `## Analytics Overview (${periodLabel})\n\n`;
87
+ if (currentDate)
88
+ md += `_Today: ${currentDate}_\n\n`;
66
89
  md += `**${d.total_posts} posts** across **${d.total_platforms} platforms** | `;
67
90
  md += `**${formatNumber(d.total_impressions)}** impressions | `;
68
91
  md += `**${formatNumber(d.total_engagement)}** engagements | `;
@@ -95,7 +118,7 @@ export function registerAnalyticsTools(server, getClient) {
95
118
  const result = await getClient().getAccountAnalytics(params);
96
119
  if (result.error) {
97
120
  return {
98
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
121
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
99
122
  };
100
123
  }
101
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,11 +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
- board_id: z.string().optional(),
215
- title: z.string().optional(),
216
- link: z.string().optional(),
217
- }).optional().describe("Pinterest-specific options"),
233
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
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."),
235
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
236
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
237
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
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."),
218
239
  youtube: z.object({
219
240
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
220
241
  tags: z.array(z.string()).optional(),
@@ -247,11 +268,32 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
247
268
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
248
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."),
249
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."),
250
292
  }, async (params) => {
251
293
  const result = await getClient().createPost({ ...params, source: "mcp" });
252
294
  if (result.error) {
253
295
  return {
254
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
296
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
255
297
  };
256
298
  }
257
299
  const p = result.data;
@@ -308,6 +350,9 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
308
350
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
309
351
  - Pinterest: ALWAYS need an image + board_id.
310
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.
311
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.
312
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.
313
358
 
@@ -324,10 +369,12 @@ Do NOT call without required media — it will fail.`, {
324
369
  ]).optional().describe("External image/video URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
325
370
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
326
371
  pinterest: z.object({
327
- board_id: z.string().optional(),
328
- title: z.string().optional(),
329
- link: z.string().optional(),
330
- }).optional().describe("Pinterest-specific options"),
372
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
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."),
374
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
375
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
376
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
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."),
331
378
  youtube: z.object({
332
379
  title: z.string().optional().describe("Short title shown on YouTube."),
333
380
  tags: z.array(z.string()).optional(),
@@ -345,11 +392,32 @@ Do NOT call without required media — it will fail.`, {
345
392
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
346
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."),
347
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."),
348
416
  }, async (params) => {
349
417
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
350
418
  if (result.error) {
351
419
  return {
352
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
420
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
353
421
  };
354
422
  }
355
423
  const p = result.data;
@@ -403,10 +471,12 @@ Do NOT call without required media — it will fail.`, {
403
471
  contains_synthetic_media: z.boolean().optional(),
404
472
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
405
473
  pinterest: z.object({
406
- board_id: z.string().optional(),
407
- title: z.string().optional(),
408
- link: z.string().optional(),
409
- }).optional().describe("Pinterest-specific options"),
474
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
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."),
476
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
477
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
478
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
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."),
410
480
  instagram: z.object({
411
481
  share_to_feed: z.boolean().optional(),
412
482
  thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
@@ -430,11 +500,32 @@ Do NOT call without required media — it will fail.`, {
430
500
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
431
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."),
432
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."),
433
524
  }, async ({ id, ...data }) => {
434
525
  const result = await getClient().updatePost(id, data);
435
526
  if (result.error) {
436
527
  return {
437
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
528
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
438
529
  };
439
530
  }
440
531
  const p = result.data;
@@ -458,7 +549,7 @@ Do NOT call without required media — it will fail.`, {
458
549
  }, async ({ id }) => {
459
550
  const result = await getClient().deletePost(id);
460
551
  return {
461
- 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." }],
462
553
  };
463
554
  });
464
555
  server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing.
@@ -469,7 +560,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
469
560
  const result = await getClient().publishPost(id);
470
561
  if (result.error) {
471
562
  return {
472
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
563
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
473
564
  };
474
565
  }
475
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;
@@ -41,7 +49,15 @@ export interface Account {
41
49
  display_name: string;
42
50
  profile_picture: string | null;
43
51
  content_types: string[];
44
- status: string;
52
+ /**
53
+ * "active" while the OAuth token is healthy. Flips to "needs_reconnect"
54
+ * when the platform has revoked/expired the token — posts to this account
55
+ * will fail until the user reconnects.
56
+ */
57
+ status: "active" | "needs_reconnect" | string;
58
+ needs_reconnect: boolean;
59
+ /** Short reason from the platform; only present when needs_reconnect is true. */
60
+ reauth_reason?: string | null;
45
61
  connected_at: string | null;
46
62
  boards?: {
47
63
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.3.7",
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",