@omnisocials/mcp-server 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -218,6 +218,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
218
218
 
219
219
  ## Changelog
220
220
 
221
+ ### 1.20.0 (2026-08-04)
222
+
223
+ - **Engagement rate now matches the dashboard:** `get_post_analytics` / `get_posts_analytics` previously summed likes + comments + shares only, dropping LinkedIn link clicks and X quotes/bookmarks from engagement. Both tools now use the same normalization as the dashboard and `get_analytics_overview`; rates are suppressed below 10 impressions and capped at 100%.
224
+ - **Account metric semantics clarified:** LinkedIn account-level `impressions` on `get_account_analytics` are lifetime totals across all of the account's content (not a windowed count) — the tool now says so and renders each platform's scope `note`.
225
+
221
226
  ### 1.19.0 (2026-08-02)
222
227
 
223
228
  - **Alt text now reaches Instagram and LinkedIn:** per-media alt text (`{ url, alt }` / `{ id, alt }` entries) is also delivered to Instagram (image posts and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents), alongside Mastodon, Bluesky, X, and Pinterest.
package/build/index.js CHANGED
@@ -29,7 +29,7 @@ const sessionState = { activeIndex: 0 };
29
29
  const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
30
30
  const server = new McpServer({
31
31
  name: "OmniSocials",
32
- version: "1.19.0",
32
+ version: "1.20.0",
33
33
  });
34
34
  // Register all tools - pass getter function so tools always use the active workspace's client
35
35
  registerPostTools(server, getActiveClient);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Engagement / impressions normalizers — VENDORED COPY of the repo's
3
+ * `shared/analytics-metrics/index.js` (the single source of truth used by the
4
+ * backend, public API, remote MCP, and dashboard). This package publishes to
5
+ * npm standalone, so it can't require() across the monorepo — keep this file
6
+ * in sync with shared/analytics-metrics when the rules change there.
7
+ *
8
+ * WHY: the MCP tools used to compute engagement inline as
9
+ * likes+comments+shares, which silently dropped LinkedIn clicks and X
10
+ * quotes/bookmarks — so the same post showed a different engagement rate in
11
+ * MCP output than on the dashboard or /analytics/overview.
12
+ */
13
+ export declare const MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE = 10;
14
+ type Metrics = Record<string, unknown> | null | undefined;
15
+ /** Total engagement (interaction count) for a post's metrics on a platform. */
16
+ export declare function getEngagement(platform: string, metrics: Metrics): number;
17
+ /** Best-available impressions ("how many times seen") for a post's metrics. */
18
+ export declare function getImpressions(platform: string, metrics: Metrics): number;
19
+ /**
20
+ * Engagement rate as a percentage (0-100), suppressed below the impression
21
+ * floor and capped at 100%.
22
+ */
23
+ export declare function getEngagementRate(engagement: number, impressions: number): number;
24
+ export {};
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Engagement / impressions normalizers — VENDORED COPY of the repo's
3
+ * `shared/analytics-metrics/index.js` (the single source of truth used by the
4
+ * backend, public API, remote MCP, and dashboard). This package publishes to
5
+ * npm standalone, so it can't require() across the monorepo — keep this file
6
+ * in sync with shared/analytics-metrics when the rules change there.
7
+ *
8
+ * WHY: the MCP tools used to compute engagement inline as
9
+ * likes+comments+shares, which silently dropped LinkedIn clicks and X
10
+ * quotes/bookmarks — so the same post showed a different engagement rate in
11
+ * MCP output than on the dashboard or /analytics/overview.
12
+ */
13
+ // Minimum impressions before an engagement-rate percentage is meaningful.
14
+ // Below this we suppress the rate rather than render a noisy ratio.
15
+ export const MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE = 10;
16
+ // Coerce anything (number, numeric string, null, undefined) to a finite number.
17
+ const num = (v) => {
18
+ if (typeof v === "number")
19
+ return Number.isFinite(v) ? v : 0;
20
+ const n = Number(v);
21
+ return Number.isFinite(n) ? n : 0;
22
+ };
23
+ /** Total engagement (interaction count) for a post's metrics on a platform. */
24
+ export function getEngagement(platform, metrics) {
25
+ const m = metrics || {};
26
+ // Trust a precomputed engagement count when a fetcher already supplied one
27
+ // (Instagram total_interactions, TikTok/Threads/Pinterest sums, Google
28
+ // Business account engagement). Only when it's a real positive number —
29
+ // a stored 0 means "not computed" for the platforms that don't set it.
30
+ const pre = num(m.engagement);
31
+ if (pre > 0)
32
+ return pre;
33
+ const sum = (...keys) => keys.reduce((t, k) => t + num(m[k]), 0);
34
+ switch (platform) {
35
+ case "facebook":
36
+ // total_reactions already aggregates like/love/wow/...; fall back to the
37
+ // bare like count only when the aggregate is absent (never add both).
38
+ return (num(m.total_reactions) || num(m.likes)) + num(m.comments) + num(m.shares);
39
+ case "linkedin":
40
+ case "linkedin_page":
41
+ // LinkedIn's native engagement includes link clicks.
42
+ return sum("likes", "comments", "shares", "clicks");
43
+ case "youtube":
44
+ return sum("likes", "comments", "shares");
45
+ case "x":
46
+ // X's engagement total includes bookmarks (saves).
47
+ return sum("likes", "comments", "reposts", "quotes", "bookmarks");
48
+ case "threads":
49
+ // Threads stores replies (not comments) as the comment-equivalent.
50
+ return num(m.likes) + (num(m.comments) || num(m.replies)) + num(m.reposts) + num(m.quotes);
51
+ case "bluesky":
52
+ return sum("likes", "comments", "reposts", "quotes");
53
+ case "mastodon":
54
+ return sum("likes", "comments", "reposts");
55
+ case "reddit":
56
+ // likes == upvote score; shares == crossposts.
57
+ return sum("likes", "comments", "shares");
58
+ case "instagram":
59
+ return sum("likes", "comments", "saves", "shares");
60
+ case "tiktok":
61
+ return sum("likes", "comments", "shares");
62
+ case "pinterest":
63
+ return sum("saves", "pin_clicks", "outbound_clicks");
64
+ case "google_business":
65
+ // engagement is precomputed at account level; nothing to sum per post.
66
+ return pre;
67
+ default:
68
+ // Generic safe sum of common interaction fields for any future platform.
69
+ return sum("likes", "comments", "replies", "shares", "reposts", "quotes", "saves");
70
+ }
71
+ }
72
+ /** Best-available impressions ("how many times seen") for a post's metrics. */
73
+ export function getImpressions(platform, metrics) {
74
+ const m = metrics || {};
75
+ switch (platform) {
76
+ case "instagram":
77
+ // Reels/video report `views` (play count) which is the true total-seen
78
+ // number and exceeds unique `reach`. Prefer it; fall back for old rows.
79
+ return num(m.views) || num(m.impressions) || num(m.reach);
80
+ case "facebook":
81
+ // Facebook exposes reach/impressions; video posts also report video_views.
82
+ return num(m.reach) || num(m.impressions) || num(m.video_views) || num(m.views);
83
+ case "linkedin":
84
+ case "linkedin_page":
85
+ case "pinterest":
86
+ case "google_business":
87
+ return num(m.impressions) || num(m.views) || num(m.reach);
88
+ case "youtube":
89
+ case "tiktok":
90
+ case "x":
91
+ case "threads":
92
+ case "bluesky":
93
+ case "mastodon":
94
+ case "reddit":
95
+ default:
96
+ return num(m.views) || num(m.impressions) || num(m.reach);
97
+ }
98
+ }
99
+ /**
100
+ * Engagement rate as a percentage (0-100), suppressed below the impression
101
+ * floor and capped at 100%.
102
+ */
103
+ export function getEngagementRate(engagement, impressions) {
104
+ const imp = num(impressions);
105
+ if (imp < MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE)
106
+ return 0;
107
+ const rate = (num(engagement) / imp) * 100;
108
+ if (!Number.isFinite(rate) || rate < 0)
109
+ return 0;
110
+ return Math.min(rate, 100);
111
+ }
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { formatNumber, capitalize, metricRows, sortMetricLabels } from "../client.js";
3
+ import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
3
4
  export function registerAnalyticsTools(server, getClient) {
4
5
  server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported — impressions, reach, views, saves, likes, comments, shares, clicks, engagements, and more — per platform.", {
5
6
  post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
@@ -36,12 +37,14 @@ export function registerAnalyticsTools(server, getClient) {
36
37
  for (const [platform, entry] of platformEntries) {
37
38
  const m = entry?.metrics || {};
38
39
  const rows = metricRows(m);
39
- const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
40
- const comments = Number(m.comments ?? m.replies ?? 0);
41
- const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
42
- const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
43
- // Denominator for engagement rate: the broadest exposure count present.
44
- const denom = Number(m.impressions ?? m.views ?? m.reach ?? 0);
40
+ // Engagement/impressions come from the shared normalizer (vendored in
41
+ // ../metrics.js) so the rate shown here matches the dashboard,
42
+ // /analytics/overview, and recent-platform. The previous inline
43
+ // likes+comments+shares sum silently dropped LinkedIn clicks and X
44
+ // quotes/bookmarks, so the same post showed a different engagement
45
+ // rate depending on the surface.
46
+ const engagements = getEngagement(platform, m);
47
+ const denom = getImpressions(platform, m);
45
48
  totalEngagements += engagements;
46
49
  totalDenom += denom;
47
50
  for (const [label, n] of rows) {
@@ -60,7 +63,11 @@ export function registerAnalyticsTools(server, getClient) {
60
63
  if (!totalsByLabel.has("Engagements"))
61
64
  totalOrder.push("Engagements");
62
65
  totalsByLabel.set("Engagements", totalEngagements);
63
- const renderRate = (engagements, denom) => denom > 0 ? `| **Engagement Rate** | ${((engagements / denom) * 100).toFixed(2)}% |\n` : "";
66
+ // Suppressed below the shared impression floor (tiny denominators make
67
+ // noisy percentages), matching every other surface.
68
+ const renderRate = (engagements, denom) => denom >= MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE
69
+ ? `| **Engagement Rate** | ${getEngagementRate(engagements, denom).toFixed(2)}% |\n`
70
+ : "";
64
71
  let md = perPlatform.length === 1
65
72
  ? `## Post Analytics — ${capitalize(perPlatform[0].platform)}\n`
66
73
  : `## Post Analytics\n\n`;
@@ -112,18 +119,17 @@ export function registerAnalyticsTools(server, getClient) {
112
119
  let likes = 0;
113
120
  let comments = 0;
114
121
  let shares = 0;
115
- for (const [, p] of platformEntries) {
122
+ for (const [name, p] of platformEntries) {
116
123
  const m = p?.metrics || {};
117
- // Broadest exposure count present, regardless of the platform's name
118
- // for it (LinkedIn: impressions, TikTok/X/YouTube: views, IG: reach).
119
- impressions += Number(m.impressions ?? m.views ?? m.reach ?? 0);
120
- const l = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
121
- const c = Number(m.comments ?? m.replies ?? 0);
122
- const s = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
123
- likes += l;
124
- comments += c;
125
- shares += s;
126
- engagements += m.engagement != null ? Number(m.engagement) : l + c + s;
124
+ // Shared normalizer keeps these totals identical to get_post_analytics,
125
+ // the dashboard, and /analytics/overview (LinkedIn clicks and X
126
+ // quotes/bookmarks count toward engagement; the old inline sum dropped
127
+ // them). Likes/comments/shares stay as display columns only.
128
+ impressions += getImpressions(name, m);
129
+ engagements += getEngagement(name, m);
130
+ likes += Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
131
+ comments += Number(m.comments ?? m.replies ?? 0);
132
+ shares += Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
127
133
  }
128
134
  return {
129
135
  post_id: entry.post_id,
@@ -195,7 +201,7 @@ export function registerAnalyticsTools(server, getClient) {
195
201
  content: [{ type: "text", text: md }],
196
202
  };
197
203
  });
198
- server.tool("get_account_analytics", "Get account-level analytics (followers, subscribers, etc.) for connected social accounts.", {
204
+ server.tool("get_account_analytics", "Get account-level analytics (followers, subscribers, etc.) for connected social accounts. IMPORTANT metric semantics: account-level `impressions` for LinkedIn (profile and page) are LIFETIME cumulative totals across ALL of the account's content — including posts published outside OmniSocials — as of the snapshot date, NOT a daily or windowed count. They cannot be compared to a windowed export (e.g. LinkedIn's native 90-day analytics). Other platforms may not report account-level impressions at all. Each metrics object carries a `note` explaining its scope where semantics are non-obvious — always relay that note to the user.", {
199
205
  platform: z.string().optional().describe("Filter by platform (e.g., instagram, youtube)"),
200
206
  date: z.string().optional().describe("Date to get analytics for (YYYY-MM-DD, defaults to today)"),
201
207
  }, async (params) => {
@@ -221,6 +227,7 @@ export function registerAnalyticsTools(server, getClient) {
221
227
  const fb = b.metrics?.followers ?? 0;
222
228
  return fb - fa;
223
229
  });
230
+ const notes = [];
224
231
  for (const a of sorted) {
225
232
  const m = a.metrics || {};
226
233
  const followers = m.followers !== undefined
@@ -245,6 +252,14 @@ export function registerAnalyticsTools(server, getClient) {
245
252
  details.push(`${formatNumber(m.subscribers)} subscribers`);
246
253
  }
247
254
  md += `| ${capitalize(a.platform)} | ${followers} | ${details.join(", ") || "—"} |\n`;
255
+ // Scope notes (e.g. "LinkedIn impressions are lifetime totals") must
256
+ // reach the user, or lifetime counters get read as windowed numbers.
257
+ if (typeof m.note === "string" && m.note) {
258
+ notes.push(`- **${capitalize(a.platform)}:** ${m.note}`);
259
+ }
260
+ }
261
+ if (notes.length) {
262
+ md += `\n${notes.join("\n")}\n`;
248
263
  }
249
264
  return {
250
265
  content: [{ type: "text", text: md }],
@@ -24,6 +24,30 @@ const mediaIdEntry = z.union([
24
24
  alt: z.string().max(1500).optional().describe(ALT_TEXT_DESCRIBE),
25
25
  }),
26
26
  ]).describe("Library media ID — a plain string, or { id, alt } to attach an accessibility description (alt text).");
27
+ // Pinterest carousels cap at 5 images (Pinterest v5 `multiple_image_urls`);
28
+ // videos are exempt — they publish as single video pins. Mirrors the
29
+ // server-side create-time check in shared/post-validation with the identical
30
+ // message; failing here just saves the agent an API round-trip. Only the
31
+ // per-platform object form is checked — with a flat array this field alone
32
+ // can't tell whether Pinterest is selected, so the server covers that case.
33
+ const VIDEO_URL_RE = /\.(mp4|mov|webm|m4v|avi|mkv)(\?|$)/i;
34
+ const pinterestImageCap = (value, ctx) => {
35
+ if (!value || typeof value !== "object" || Array.isArray(value))
36
+ return;
37
+ const entries = value.pinterest;
38
+ if (!Array.isArray(entries))
39
+ return;
40
+ const imageCount = entries.filter((entry) => {
41
+ const url = typeof entry === "string" ? entry : entry?.url;
42
+ return !(typeof url === "string" && VIDEO_URL_RE.test(url));
43
+ }).length;
44
+ if (imageCount > 5) {
45
+ ctx.addIssue({
46
+ code: z.ZodIssueCode.custom,
47
+ message: "Pinterest carousel pins support a maximum of 5 images. Please remove some images and try again.",
48
+ });
49
+ }
50
+ };
27
51
  // Reusable per-platform option objects for the "first comment" feature — text
28
52
  // auto-posted as a comment on the post right after it publishes (hashtags out
29
53
  // of the caption, "link in first comment", etc.). Only platforms with a
@@ -427,7 +451,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
427
451
  media_urls: z.union([
428
452
  z.array(mediaUrlEntry),
429
453
  z.record(z.string(), z.array(mediaUrlEntry)),
430
- ]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB. 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. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
454
+ ]).superRefine(pinterestImageCap).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — 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. 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. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
431
455
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
432
456
  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)."),
433
457
  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."),
@@ -450,7 +474,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
450
474
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
451
475
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
452
476
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
453
- }).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."),
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."),
454
478
  youtube: z.object({
455
479
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
456
480
  tags: z.array(z.string()).optional(),
@@ -608,7 +632,7 @@ Do NOT call without required media — it will fail.`, {
608
632
  media_urls: z.union([
609
633
  z.array(mediaUrlEntry),
610
634
  z.record(z.string(), z.array(mediaUrlEntry)),
611
- ]).optional().describe("External image/video 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, 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."),
635
+ ]).superRefine(pinterestImageCap).optional().describe("External image/video 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."),
612
636
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
613
637
  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."),
614
638
  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."),
@@ -627,7 +651,7 @@ Do NOT call without required media — it will fail.`, {
627
651
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
628
652
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
629
653
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
630
- }).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."),
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."),
631
655
  youtube: z.object({
632
656
  title: z.string().optional().describe("Short title shown on YouTube."),
633
657
  tags: z.array(z.string()).optional(),
@@ -737,7 +761,7 @@ Do NOT call without required media — it will fail.`, {
737
761
  });
738
762
  server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
739
763
 
740
- **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
764
+ **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
741
765
 
742
766
  **X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread.`, {
743
767
  id: z.string().describe("The post ID to update"),
@@ -751,7 +775,7 @@ Do NOT call without required media — it will fail.`, {
751
775
  media_urls: z.union([
752
776
  z.array(mediaUrlEntry),
753
777
  z.record(z.string(), z.array(mediaUrlEntry)),
754
- ]).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, 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."),
778
+ ]).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."),
755
779
  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."),
756
780
  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."),
757
781
  user_tags: z.array(z.object({
@@ -776,7 +800,7 @@ Do NOT call without required media — it will fail.`, {
776
800
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
777
801
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
778
802
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
779
- }).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."),
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."),
780
804
  instagram: z.object({
781
805
  share_to_feed: z.boolean().optional(),
782
806
  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."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
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",