@omnisocials/mcp-server 1.18.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,16 @@ 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
+
226
+ ### 1.19.0 (2026-08-02)
227
+
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.
229
+ - **`duration_seconds` on `get_recent_platform_posts`:** video length in whole seconds where the platform's listing API reports it (currently TikTok and YouTube; `null` for images and platforms that don't expose it) — rendered as a Duration column (m:ss) in the summary table and included in the `Structured data` JSON block.
230
+
221
231
  ### 1.18.0
222
232
 
223
233
  - **`retry_post`:** retry the failed platforms of a `failed` or partially failed (`warning`) post on the same post — only failed platforms are re-published, succeeded ones never post twice. Async via the publishing queue; poll `get_post` for the outcome. Max 3 retries per platform. Backed by `POST /api/v1/posts/{id}/retry`.
@@ -226,7 +236,7 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
226
236
 
227
237
  ### 1.17.0
228
238
 
229
- - **Per-media alt text (accessibility descriptions):** `media_urls` / `media_ids` entries now accept `{ url, alt }` / `{ id, alt }` objects everywhere, including thread parts. Delivered to Mastodon (media description), Bluesky (embed alt), X (photos/GIFs), and Pinterest (pin `alt_text` fallback). `get_post` reads alt text back.
239
+ - **Per-media alt text (accessibility descriptions):** `media_urls` / `media_ids` entries now accept `{ url, alt }` / `{ id, alt }` objects everywhere, including thread parts. Delivered to Mastodon (media description), Bluesky (embed alt), X (media metadata, photos/GIFs only) and Pinterest (pin alt_text fallback). `get_post` reads alt text back.
230
240
 
231
241
  ### 1.16.0
232
242
 
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.18.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 }],
@@ -142,7 +142,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
142
142
  if (compatibility && compatibility.compatible === false && compatibility.summary) {
143
143
  md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before adding it to a post.`;
144
144
  }
145
- md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted. To attach alt text (an accessibility description), pass \`{ id: "<Media ID>", alt: "..." }\` instead of the bare ID — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest.`;
145
+ md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted. To attach alt text (an accessibility description), pass \`{ id: "<Media ID>", alt: "..." }\` instead of the bare ID — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images).`;
146
146
  return {
147
147
  content: [{ type: "text", text: md }],
148
148
  };
@@ -5,9 +5,11 @@ import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "
5
5
  // or an object carrying `alt`: an accessibility description (alt text, max
6
6
  // 1500 chars) for that file. Alt text is delivered to Mastodon (media
7
7
  // description — the community strongly values alt text), Bluesky (embed alt),
8
- // X (photos/GIFs only), and Pinterest (used as the pin alt_text when
9
- // pinterest.alt_text is not set); other platforms ignore it for now.
10
- const ALT_TEXT_DESCRIBE = "Accessibility description (alt text) for this file, max 1500 chars. Delivered to Mastodon (media description), Bluesky (embed alt), X (photos/GIFs only), and Pinterest (used as the pin alt_text when pinterest.alt_text is not set); other platforms ignore it.";
8
+ // X (media metadata, photos/GIFs only), Pinterest (pin alt_text fallback),
9
+ // Instagram (images and carousel image slides not Reels/Stories, capped at
10
+ // 1000 chars) and LinkedIn (images only not video or documents); other
11
+ // platforms ignore it for now.
12
+ const ALT_TEXT_DESCRIBE = "Accessibility description (alt text) for this file, max 1500 chars. Delivered to Mastodon (media description), Bluesky (embed alt), X (media metadata, photos/GIFs only), Pinterest (pin alt_text fallback), Instagram (images and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents). Other platforms ignore it.";
11
13
  const mediaUrlEntry = z.union([
12
14
  z.string(),
13
15
  z.object({
@@ -22,6 +24,30 @@ const mediaIdEntry = z.union([
22
24
  alt: z.string().max(1500).optional().describe(ALT_TEXT_DESCRIBE),
23
25
  }),
24
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
+ };
25
51
  // Reusable per-platform option objects for the "first comment" feature — text
26
52
  // auto-posted as a comment on the post right after it publishes (hashtags out
27
53
  // of the caption, "link in first comment", etc.). Only platforms with a
@@ -421,11 +447,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
421
447
  media_ids: z.union([
422
448
  z.array(mediaIdEntry),
423
449
  z.record(z.string(), z.array(mediaIdEntry)),
424
- ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest."),
450
+ ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
425
451
  media_urls: z.union([
426
452
  z.array(mediaUrlEntry),
427
453
  z.record(z.string(), z.array(mediaUrlEntry)),
428
- ]).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 (photos/GIFs), and Pinterest. 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`."),
429
455
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
430
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)."),
431
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."),
@@ -448,7 +474,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
448
474
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
449
475
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
450
476
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
451
- }).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."),
452
478
  youtube: z.object({
453
479
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
454
480
  tags: z.array(z.string()).optional(),
@@ -602,11 +628,11 @@ Do NOT call without required media — it will fail.`, {
602
628
  media_ids: z.union([
603
629
  z.array(mediaIdEntry),
604
630
  z.record(z.string(), z.array(mediaIdEntry)),
605
- ]).optional().describe("Media IDs from upload — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest."),
631
+ ]).optional().describe("Media IDs from upload — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
606
632
  media_urls: z.union([
607
633
  z.array(mediaUrlEntry),
608
634
  z.record(z.string(), z.array(mediaUrlEntry)),
609
- ]).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 (photos/GIFs), and Pinterest. 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."),
610
636
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
611
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."),
612
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."),
@@ -625,7 +651,7 @@ Do NOT call without required media — it will fail.`, {
625
651
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
626
652
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
627
653
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
628
- }).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."),
629
655
  youtube: z.object({
630
656
  title: z.string().optional().describe("Short title shown on YouTube."),
631
657
  tags: z.array(z.string()).optional(),
@@ -735,7 +761,7 @@ Do NOT call without required media — it will fail.`, {
735
761
  });
736
762
  server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
737
763
 
738
- **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.
739
765
 
740
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.`, {
741
767
  id: z.string().describe("The post ID to update"),
@@ -745,11 +771,11 @@ Do NOT call without required media — it will fail.`, {
745
771
  media_ids: z.union([
746
772
  z.array(mediaIdEntry),
747
773
  z.record(z.string(), z.array(mediaIdEntry)),
748
- ]).optional().describe("Media IDs — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest."),
774
+ ]).optional().describe("Media IDs — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images)."),
749
775
  media_urls: z.union([
750
776
  z.array(mediaUrlEntry),
751
777
  z.record(z.string(), z.array(mediaUrlEntry)),
752
- ]).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 (photos/GIFs), and Pinterest. 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."),
753
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."),
754
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."),
755
781
  user_tags: z.array(z.object({
@@ -774,7 +800,7 @@ Do NOT call without required media — it will fail.`, {
774
800
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
775
801
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
776
802
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
777
- }).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."),
778
804
  instagram: z.object({
779
805
  share_to_feed: z.boolean().optional(),
780
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."),
@@ -985,7 +1011,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
985
1011
  });
986
1012
  return { content: [{ type: "text", text: md }] };
987
1013
  });
988
- server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
1014
+ server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
989
1015
  limit: z
990
1016
  .string()
991
1017
  .optional()
@@ -1006,6 +1032,14 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1006
1032
  const errors = result.errors || {};
1007
1033
  const errEntries = Object.entries(errors);
1008
1034
  const clean = (s) => String(s || "").replace(/[|\n\r]+/g, " ").trim();
1035
+ // Video length as m:ss (e.g. 95 → "1:35"); blank for images and
1036
+ // platforms whose listing API reports no duration.
1037
+ const fmtVideoDuration = (secs) => {
1038
+ if (typeof secs !== "number" || !Number.isFinite(secs))
1039
+ return "—";
1040
+ const s = Math.round(secs);
1041
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
1042
+ };
1009
1043
  if (!posts.length) {
1010
1044
  const note = result.note || "No recent platform posts found.";
1011
1045
  const errLines = errEntries.map(([p, m]) => `- ${capitalize(p)}: ${m}`).join("\n");
@@ -1014,8 +1048,8 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1014
1048
  };
1015
1049
  }
1016
1050
  let md = `## Recent platform posts (${posts.length} across ${connected.length} platform${connected.length === 1 ? "" : "s"})\n\n`;
1017
- md += `| # | Platform | Format | Content | Engagement | Metrics | Date |\n`;
1018
- md += `|---|----------|--------|---------|------------|---------|------|\n`;
1051
+ md += `| # | Platform | Format | Duration | Content | Engagement | Metrics | Date |\n`;
1052
+ md += `|---|----------|--------|----------|---------|------------|---------|------|\n`;
1019
1053
  posts.forEach((p, i) => {
1020
1054
  const hasMetrics = p.metrics && Object.keys(p.metrics).length > 0;
1021
1055
  const content = truncate(clean(p.text), 45) || "*(no caption)*";
@@ -1027,7 +1061,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1027
1061
  .map(([label, n]) => `${formatNumber(n)} ${label.toLowerCase()}`)
1028
1062
  .join(" · ");
1029
1063
  const date = p.timestamp ? formatDateTime(p.timestamp) : "—";
1030
- md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
1064
+ md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${fmtVideoDuration(p.duration_seconds)} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
1031
1065
  });
1032
1066
  if (result.note)
1033
1067
  md += `\n${result.note}\n`;
@@ -1049,6 +1083,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1049
1083
  text: p.text ?? "",
1050
1084
  format: p.format ?? "post",
1051
1085
  media_count: p.media_count ?? 1,
1086
+ duration_seconds: p.duration_seconds ?? null,
1052
1087
  timestamp: p.timestamp ?? null,
1053
1088
  image_url: p.image_url ?? null,
1054
1089
  engagement: p.engagement ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.18.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",