@omnisocials/mcp-server 1.24.0 → 1.27.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/build/client.d.ts CHANGED
@@ -9,6 +9,9 @@ export declare function formatDate(iso: string | null): string;
9
9
  export declare function formatDateTime(iso: string | null): string;
10
10
  export declare function truncate(value: unknown, len: number): string;
11
11
  export declare function capitalize(str: string): string;
12
+ /** Breakdown objects (reactions by type, traffic sources, ...) rendered as
13
+ * "Label: a 12, b 3" lines under the metric table. */
14
+ export declare function metricBreakdownRows(m: Record<string, unknown> | null | undefined): Array<[string, string]>;
12
15
  export declare function sortMetricLabels(labels: string[]): string[];
13
16
  /**
14
17
  * Flatten a raw per-platform metrics object into ordered [label, value] rows,
package/build/client.js CHANGED
@@ -107,14 +107,60 @@ const METRIC_LABELS = [
107
107
  ["quotes", "Quotes"],
108
108
  ["bookmarks", "Bookmarks"],
109
109
  ["replies", "Replies"],
110
- ["reactions", "Reactions"],
111
110
  ["total_reactions", "Total reactions"],
112
111
  ["favorites", "Favorites"],
113
112
  ["plays", "Plays"],
114
113
  ["engagement", "Engagements"],
114
+ ["link_clicks", "Link clicks"],
115
+ ["profile_visits", "Profile visits"],
116
+ ["follows", "Follows from post"],
117
+ ["pin_clicks", "Pin clicks"],
118
+ ["outbound_clicks", "Outbound clicks"],
119
+ ["video_views", "Video views"],
120
+ ["engaged_views", "Engaged views"],
121
+ ["avg_watch_time", "Avg. watch time (s)"],
122
+ ["total_watch_time", "Total watch time (s)"],
123
+ ["watch_time_percentage", "Watched (%)"],
124
+ ["completion_rate", "Completion rate (%)"],
125
+ ["skip_rate", "Skip rate (%)"],
126
+ ["replays", "Replays"],
127
+ ["subscribers_gained", "Subscribers gained"],
128
+ ["subscribers_lost", "Subscribers lost"],
129
+ ["duration", "Duration (s)"],
115
130
  ];
116
- // Context keys that ride along in stored metrics not counters.
117
- const NON_COUNTER_KEYS = new Set(["create_time"]);
131
+ // Context keys that ride along in stored metrics, not counters.
132
+ const NON_COUNTER_KEYS = new Set(["create_time", "story_slide_index"]);
133
+ /** Breakdown objects (reactions by type, traffic sources, ...) rendered as
134
+ * "Label: a 12, b 3" lines under the metric table. */
135
+ export function metricBreakdownRows(m) {
136
+ const LABELS = {
137
+ reactions: "Reactions by type",
138
+ clicks_breakdown: "Clicks by type",
139
+ profile_activity_breakdown: "Profile actions",
140
+ navigation_breakdown: "Story navigation",
141
+ completion_analysis: "Story completion",
142
+ traffic_sources: "Traffic sources",
143
+ impression_sources: "Where it was seen",
144
+ audience_types: "Viewer types",
145
+ audience_countries: "Top countries",
146
+ impressions_breakdown: "Impressions by surface",
147
+ online_followers: "Followers online by hour (UTC)",
148
+ };
149
+ const rows = [];
150
+ for (const [key, label] of Object.entries(LABELS)) {
151
+ const v = m?.[key];
152
+ if (!v || typeof v !== "object" || Array.isArray(v))
153
+ continue;
154
+ const parts = Object.entries(v)
155
+ .filter(([, n]) => Number.isFinite(Number(n)))
156
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
157
+ .slice(0, 8)
158
+ .map(([k, n]) => `${k.replace(/_/g, " ")} ${Number(n) <= 1 && key.endsWith("_sources") ? `${Math.round(Number(n) * 100)}%` : formatNumber(Number(n))}`);
159
+ if (parts.length)
160
+ rows.push([label, parts.join(", ")]);
161
+ }
162
+ return rows;
163
+ }
118
164
  /** Canonical position of a metric label — for sorting totals rows collected
119
165
  * across platforms back into METRIC_LABELS order (unknown labels sink). */
120
166
  const LABEL_ORDER = new Map(METRIC_LABELS.map(([, label], i) => [label, i]));
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.24.0",
32
+ version: "1.27.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);
@@ -16,6 +16,8 @@ type Metrics = Record<string, unknown> | null | undefined;
16
16
  export declare function getEngagement(platform: string, metrics: Metrics): number;
17
17
  /** Best-available impressions ("how many times seen") for a post's metrics. */
18
18
  export declare function getImpressions(platform: string, metrics: Metrics): number;
19
+ /** Unique accounts reached, when the platform reports it (0 otherwise). */
20
+ export declare function getReach(platform: string, metrics: Metrics): number;
19
21
  /**
20
22
  * Engagement rate as a percentage (0-100), suppressed below the impression
21
23
  * floor and capped at 100%.
package/build/metrics.js CHANGED
@@ -43,24 +43,33 @@ export function getEngagement(platform, metrics) {
43
43
  case "youtube":
44
44
  return sum("likes", "comments", "shares");
45
45
  case "x":
46
- // X's engagement total includes bookmarks (saves).
46
+ // X's engagement total includes bookmarks (saves). A native
47
+ // non_public_metrics.engagements value, when stored, wins above.
47
48
  return sum("likes", "comments", "reposts", "quotes", "bookmarks");
48
49
  case "threads":
49
50
  // 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
+ return (num(m.likes) +
52
+ (num(m.comments) || num(m.replies)) +
53
+ num(m.reposts) +
54
+ num(m.quotes) +
55
+ num(m.shares));
51
56
  case "bluesky":
52
- return sum("likes", "comments", "reposts", "quotes");
57
+ return sum("likes", "comments", "reposts", "quotes", "bookmarks");
53
58
  case "mastodon":
54
- return sum("likes", "comments", "reposts");
59
+ return sum("likes", "comments", "reposts", "quotes");
55
60
  case "reddit":
56
61
  // likes == upvote score; shares == crossposts.
57
62
  return sum("likes", "comments", "shares");
58
63
  case "instagram":
59
- return sum("likes", "comments", "saves", "shares");
64
+ // Stories carry total_interactions (replies, shares, profile taps)
65
+ // instead of likes/comments; use it when present.
66
+ return num(m.total_interactions) || sum("likes", "comments", "saves", "shares");
60
67
  case "tiktok":
61
- return sum("likes", "comments", "shares");
68
+ // favorites == saves on TikTok (Business API only).
69
+ return sum("likes", "comments", "shares", "favorites");
62
70
  case "pinterest":
63
- return sum("saves", "pin_clicks", "outbound_clicks");
71
+ // likes == TOTAL_REACTIONS, comments == TOTAL_COMMENTS.
72
+ return sum("saves", "pin_clicks", "outbound_clicks", "likes", "comments");
64
73
  case "google_business":
65
74
  // engagement is precomputed at account level; nothing to sum per post.
66
75
  return pre;
@@ -78,8 +87,9 @@ export function getImpressions(platform, metrics) {
78
87
  // number and exceeds unique `reach`. Prefer it; fall back for old rows.
79
88
  return num(m.views) || num(m.impressions) || num(m.reach);
80
89
  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);
90
+ // views == post_media_view (total), reach == unique viewers. Prefer the
91
+ // total like every other platform; video posts also report video_views.
92
+ return num(m.views) || num(m.video_views) || num(m.impressions) || num(m.reach);
83
93
  case "linkedin":
84
94
  case "linkedin_page":
85
95
  case "pinterest":
@@ -96,6 +106,11 @@ export function getImpressions(platform, metrics) {
96
106
  return num(m.views) || num(m.impressions) || num(m.reach);
97
107
  }
98
108
  }
109
+ /** Unique accounts reached, when the platform reports it (0 otherwise). */
110
+ export function getReach(platform, metrics) {
111
+ const m = metrics || {};
112
+ return num(m.reach) || num(m.unique_impressions) || num(m.members_reached);
113
+ }
99
114
  /**
100
115
  * Engagement rate as a percentage (0-100), suppressed below the impression
101
116
  * floor and capped at 100%.
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { formatNumber, capitalize, metricRows, sortMetricLabels } from "../client.js";
2
+ import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels } from "../client.js";
3
3
  import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
4
4
  export function registerAnalyticsTools(server, getClient) {
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
+ 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. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
6
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."),
7
7
  }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
@@ -55,9 +55,11 @@ export function registerAnalyticsTools(server, getClient) {
55
55
  perPlatform.push({
56
56
  platform,
57
57
  rows,
58
+ breakdowns: metricBreakdownRows(m),
58
59
  engagements,
59
60
  denom,
60
61
  note: typeof m.note === "string" ? m.note : undefined,
62
+ storySlides: Array.isArray(entry?.story_slides) ? entry.story_slides : undefined,
61
63
  });
62
64
  }
63
65
  if (!totalsByLabel.has("Engagements"))
@@ -87,8 +89,24 @@ export function registerAnalyticsTools(server, getClient) {
87
89
  md += `| **${label}** | ${formatNumber(n)} |\n`;
88
90
  }
89
91
  md += renderRate(p.engagements, p.denom);
92
+ for (const [label, text] of p.breakdowns) {
93
+ md += `| **${label}** | ${text} |\n`;
94
+ }
90
95
  if (p.note)
91
96
  md += `\n_${p.note}_\n`;
97
+ // Multi-slide stories: the platform table above is the sum; show each
98
+ // slide on its own so the user can see which slide held attention.
99
+ if (p.storySlides && p.storySlides.length > 1) {
100
+ md += `\n_${p.storySlides.length} story slides (totals above are the sum):_\n`;
101
+ for (const slide of p.storySlides) {
102
+ const sm = slide.metrics || {};
103
+ const slideRows = metricRows(sm);
104
+ md += `\n**Story ${slide.index + 1}**\n\n| Metric | Value |\n|--------|-------|\n`;
105
+ for (const [label, n] of slideRows) {
106
+ md += `| **${label}** | ${formatNumber(n)} |\n`;
107
+ }
108
+ }
109
+ }
92
110
  }
93
111
  return {
94
112
  content: [{ type: "text", text: md }],
@@ -127,7 +145,7 @@ export function registerAnalyticsTools(server, getClient) {
127
145
  // them). Likes/comments/shares stay as display columns only.
128
146
  impressions += getImpressions(name, m);
129
147
  engagements += getEngagement(name, m);
130
- likes += Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
148
+ likes += (Number(m.likes ?? m.favorites ?? 0) || 0);
131
149
  comments += Number(m.comments ?? m.replies ?? 0);
132
150
  shares += Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
133
151
  }
@@ -205,7 +223,7 @@ export function registerAnalyticsTools(server, getClient) {
205
223
  content: [{ type: "text", text: md }],
206
224
  };
207
225
  });
208
- 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.", {
226
+ server.tool("get_account_analytics", "Get account-level analytics for connected social accounts: followers, following, posts, and the day values the platform reports for the snapshot date (impressions/views, reach, engagement, profile_views, link_clicks, follows_gained, follows_lost, Google Business calls/direction_requests/website_clicks/average_rating/review_count, Pinterest monthly_views). Rows with day values carry `period: \"daily\"`. Audience objects appear when available: `demographics` (+ demographics_unit) and `online_followers` (UTC hour to followers online). Metric semantics: LinkedIn profile rows keep a lifetime total under `impressions_lifetime` and `period: \"lifetime\"` when no daily breakdown is served. A missing key means the platform does not report it. Each metrics object carries a `note` explaining its scope where semantics are non-obvious; always relay that note to the user.", {
209
227
  platform: z.string().optional().describe("Filter by platform (e.g., instagram, youtube)"),
210
228
  date: z.string().optional().describe("Date to get analytics for (YYYY-MM-DD, defaults to today)"),
211
229
  }, async (params) => {
@@ -240,21 +258,43 @@ export function registerAnalyticsTools(server, getClient) {
240
258
  ? `${formatNumber(m.subscribers)} subs`
241
259
  : "—";
242
260
  const details = [];
243
- if (m.posts !== undefined)
244
- details.push(`${formatNumber(m.posts)} posts`);
245
- if (m.following !== undefined)
246
- details.push(`${formatNumber(m.following)} following`);
247
- if (m.impressions !== undefined)
248
- details.push(`${formatNumber(m.impressions)} impressions`);
249
- if (m.engagement !== undefined)
250
- details.push(`${formatNumber(m.engagement)} engagements`);
251
- if (m.total_views !== undefined)
252
- details.push(`${formatNumber(m.total_views)} views`);
253
- if (m.total_videos !== undefined)
254
- details.push(`${m.total_videos} videos`);
261
+ const ACCOUNT_DETAIL_KEYS = [
262
+ ["posts", "posts"],
263
+ ["following", "following"],
264
+ ["impressions", "views"],
265
+ ["reach", "reach"],
266
+ ["engagement", "engagements"],
267
+ ["profile_views", "profile views"],
268
+ ["link_clicks", "link clicks"],
269
+ ["follows_gained", "new followers"],
270
+ ["follows_lost", "lost followers"],
271
+ ["accounts_engaged", "accounts engaged"],
272
+ ["video_views", "video views"],
273
+ ["watch_time_minutes", "min watched"],
274
+ ["monthly_views", "monthly views"],
275
+ ["total_views", "lifetime views"],
276
+ ["total_videos", "videos"],
277
+ ["listed", "listed"],
278
+ ["average_rating", "rating"],
279
+ ["review_count", "reviews"],
280
+ ["calls", "calls"],
281
+ ["direction_requests", "direction requests"],
282
+ ["website_clicks", "website clicks"],
283
+ ["bookings", "bookings"],
284
+ ["mentions", "mentions"],
285
+ ];
286
+ for (const [key, label] of ACCOUNT_DETAIL_KEYS) {
287
+ const v = m[key];
288
+ if (v === undefined || v === null || !Number.isFinite(Number(v)))
289
+ continue;
290
+ details.push(`${key === "average_rating" ? Number(v).toFixed(1) : formatNumber(Number(v))} ${label}`);
291
+ }
255
292
  if (m.subscribers !== undefined && m.followers !== undefined) {
256
293
  details.push(`${formatNumber(m.subscribers)} subscribers`);
257
294
  }
295
+ if (typeof m.period === "string") {
296
+ details.push(`(${m.period})`);
297
+ }
258
298
  md += `| ${capitalize(a.platform)} | ${followers} | ${details.join(", ") || "—"} |\n`;
259
299
  // Scope notes (e.g. "LinkedIn impressions are lifetime totals") must
260
300
  // reach the user, or lifetime counters get read as windowed numbers.
@@ -269,7 +309,7 @@ export function registerAnalyticsTools(server, getClient) {
269
309
  content: [{ type: "text", text: md }],
270
310
  };
271
311
  });
272
- server.tool("get_best_times", "Get recommended posting times (day + hour) for one platform, computed from the workspace's own posting history: publish time × engagement of every post published there, recency-weighted and outlier-damped, bucketed in the user's timezone. Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, industry-average defaults are returned instead (`basis: defaults`) with how many more posts unlock personalized recommendations — tell the user that so the numbers aren't mistaken for their own audience data. Use this before scheduling when the user asks 'when should I post?' or hasn't specified a time. Requires the analytics:read scope.", {
312
+ server.tool("get_best_times", "Get recommended posting times (day + hour) for one platform, computed from the workspace's own posting history: publish time × engagement of every post published there, recency-weighted and outlier-damped, bucketed in the user's timezone, and blended with when the account's followers are online when the platform provides that (Instagram, TikTok Business; `basis: own_data_and_audience`, response carries `audience_online`). Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, the audience-online profile alone is used (`basis: audience`) or industry-average defaults are returned (`basis: defaults`) with how many more posts unlock personalized recommendations — tell the user that so the numbers aren't mistaken for their own audience data. Use this before scheduling when the user asks 'when should I post?' or hasn't specified a time. Requires the analytics:read scope.", {
273
313
  platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business"),
274
314
  timezone: z.string().optional().describe("IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone."),
275
315
  }, async (params) => {
@@ -318,6 +358,12 @@ export function registerAnalyticsTools(server, getClient) {
318
358
  if (d.basis === "defaults") {
319
359
  md += `\n_Not enough posting history on ${platformLabel} yet (${d.sample_size} analyzed post${d.sample_size === 1 ? "" : "s"}). These are cross-industry averages — publish ${d.posts_needed} more post${d.posts_needed === 1 ? "" : "s"} to unlock recommendations from this audience's own data._\n`;
320
360
  }
361
+ else if (d.basis === "audience") {
362
+ md += `\n_Not enough posting history on ${platformLabel} yet (${d.sample_size} analyzed post${d.sample_size === 1 ? "" : "s"}), so these times come from when this account's followers are online. Publish ${d.posts_needed} more post${d.posts_needed === 1 ? "" : "s"} to blend in your own results._\n`;
363
+ }
364
+ else if (d.basis === "own_data_and_audience") {
365
+ md += `\n_Based on ${d.sample_size} ${platformLabel} posts from the last ${d.window_days} days, blended with when this account's followers are online (metric: ${d.metric}, times in ${d.timezone})._\n`;
366
+ }
321
367
  else {
322
368
  md += `\n_Based on ${d.sample_size} ${platformLabel} posts from the last ${d.window_days} days (metric: ${d.metric}, times in ${d.timezone})._\n`;
323
369
  }
@@ -131,6 +131,11 @@ const TIKTOK_OPTIONS = z.object({
131
131
  brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
132
132
  brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
133
133
  auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
134
+ first_comment: z
135
+ .string()
136
+ .max(150)
137
+ .optional()
138
+ .describe("Text auto-posted as the first comment on the TikTok video right after it publishes (max 150 characters). Requires the workspace's TikTok comments authorization ('Enable comments' on the TikTok channel card); without it first_comment_result is failed with an explanatory error. The video must be public with comments allowed. If TikTok returns the final video id a few minutes after publish, the comment posts automatically once it resolves (first_comment_result.pending is true meanwhile). On update_post pass an empty string to clear it."),
134
139
  }).optional().describe("TikTok options");
135
140
  const PINTEREST_OPTIONS = z.object({
136
141
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
@@ -245,7 +250,7 @@ function postAppUrl(p) {
245
250
  }
246
251
  export function registerPostTools(server, getClient) {
247
252
  server.tool("list_posts", "List all posts in the workspace. Optionally filter by status. The content column shows a preview of the default caption; if a post has per-platform overrides (e.g. a custom X version), the preview is suffixed with *(per-platform)* — call `get_post` to see every variant.", {
248
- status: z.string().optional().describe("Filter by status: draft, scheduled, published, failed"),
253
+ status: z.string().optional().describe("Filter by status: draft, in_approval, scheduled, posting, published, failed, warning. in_approval = waiting for a reviewer in an approval workflow."),
249
254
  limit: z.string().optional().describe("Max results to return (default: 20)"),
250
255
  offset: z.string().optional().describe("Offset for pagination"),
251
256
  }, async (params) => {
@@ -450,6 +455,23 @@ export function registerPostTools(server, getClient) {
450
455
  md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
451
456
  }
452
457
  }
458
+ // Multi-slide stories: one story per slide on the platform. Show every
459
+ // slide's own URL in publish order (published_urls only has slide 1).
460
+ const storySlides = (p.story_slides && typeof p.story_slides === "object" && !Array.isArray(p.story_slides))
461
+ ? p.story_slides
462
+ : {};
463
+ const slideEntries = Object.entries(storySlides).filter(([, list]) => Array.isArray(list) && list.length > 0);
464
+ if (slideEntries.length) {
465
+ md += `\n\n### Story Slides\n\n`;
466
+ for (const [platform, list] of slideEntries) {
467
+ md += `**${capitalize(platform.replace(/_/g, " "))}** (${list.length} slide${list.length === 1 ? "" : "s"})\n`;
468
+ list.forEach((slide, i) => {
469
+ const n = (typeof slide.index === "number" ? slide.index : i) + 1;
470
+ md += `- Slide ${n}${slide.media_type ? ` (${slide.media_type})` : ""}: ${slide.url || slide.native_post_id || "(no url)"}\n`;
471
+ });
472
+ md += `\n`;
473
+ }
474
+ }
453
475
  // Per-platform publish errors (status failed/warning). Point at
454
476
  // retry_post — only the failed platforms are re-published.
455
477
  const postErrors = (p.errors && typeof p.errors === "object" && !Array.isArray(p.errors))
@@ -467,7 +489,7 @@ export function registerPostTools(server, getClient) {
467
489
  }
468
490
  // First comments: nested under each platform object by the API. Show the
469
491
  // configured text plus, once published, the outcome (posted/failed/skipped).
470
- const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube"];
492
+ const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok"];
471
493
  const firstCommentRows = [];
472
494
  for (const platform of firstCommentPlatforms) {
473
495
  const opts = p[platform];
@@ -535,7 +557,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
535
557
  2. **Channels**: Which platforms to post to? Use list_accounts to show available options if needed.
536
558
  3. **Schedule**: When should it be published? (Or save as draft?)
537
559
  4. **Media** (REQUIRED for some types):
538
- - Stories: ALWAYS require an image or video. Ask the user which image/video to use. Use list_media to show their uploaded media, or ask for an external URL.
560
+ - Stories: ALWAYS require an image or video. A story takes 1 to 10 media items: EACH item is one slide and publishes as its own story on Instagram/Facebook, in the order given (more than 10 returns 400). Ask the user which media to use and in what order. Use list_media to show their uploaded media, or ask for external URLs. Story videos are max 60s per slide.
539
561
  - Reels: ALWAYS require a video (MP4/MOV) — NOT an image. Passing an image to a Reel returns a 400 validation_error. To share an image, use post_type "Post" instead. Ask the user which video to use; use list_media to find available videos.
540
562
  - Instagram posts: ALWAYS require at least one image or video.
541
563
  - TikTok posts: ALWAYS require at least one image or video.
@@ -580,7 +602,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
580
602
  z.array(mediaUrlEntry),
581
603
  z.record(z.string(), z.array(mediaUrlEntry)),
582
604
  ]).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`."),
583
- type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
605
+ type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat; 1 to 10 media items, each one a slide published in order), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
584
606
  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)."),
585
607
  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."),
586
608
  link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
@@ -589,7 +611,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
589
611
  collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. Invited users get an invite in the Instagram app; once they accept, the post also appears on their profile and feed. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time with a clear error. Ignored by other platforms."),
590
612
  user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
591
613
  hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 30-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
592
- hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
614
+ hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube/TikTok (TikTok only when the workspace enabled TikTok comments) (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
593
615
  hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
594
616
  pinterest: PINTEREST_OPTIONS,
595
617
  youtube: z.object({
@@ -702,7 +724,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
702
724
  1. **Content/caption**: What text? If captions differ per platform, use one call with an object: { "default": "fallback", "linkedin": "long", "threads": "short" }.
703
725
  2. **Channels**: Which platforms? Use list_accounts if needed.
704
726
  3. **Media** (REQUIRED for some types — same rules as create_post):
705
- - Stories: ALWAYS need an image or video.
727
+ - Stories: ALWAYS need an image or video. Up to 10 media items; each one is a slide, published as its own story in order.
706
728
  - Reels: ALWAYS need a video (MP4/MOV), NOT an image — passing an image returns a 400 validation_error. Use post_type "Post" to share an image.
707
729
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
708
730
  - Pinterest: ALWAYS need an image + board_id.
@@ -724,7 +746,7 @@ Do NOT call without required media — it will fail.`, {
724
746
  z.array(mediaUrlEntry),
725
747
  z.record(z.string(), z.array(mediaUrlEntry)),
726
748
  ]).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."),
727
- type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
749
+ type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (1 to 10 media items, each one a slide published in order), 'reel'"),
728
750
  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."),
729
751
  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."),
730
752
  user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
@@ -824,7 +846,7 @@ Do NOT call without required media — it will fail.`, {
824
846
  **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.`, {
825
847
  id: z.string().describe("The post ID to update"),
826
848
  content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post caption / body text. For YouTube Shorts this becomes the video description, NOT the title — to rename the Short, use `youtube.title`. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
827
- scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
849
+ scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled. A post waiting for approval keeps its in_approval status; only its time moves."),
828
850
  channels: z.array(z.string()).optional().describe("Updated channel IDs. Bare platform name or composite `\"<workspace_id>_<platform>\"`; always source from list_accounts for this API key (unknown or mismatched workspace prefixes are rejected as unknown accounts). Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
829
851
  media_ids: z.union([
830
852
  z.array(mediaIdEntry),
@@ -1039,7 +1061,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1039
1061
  });
1040
1062
  return { content: [{ type: "text", text: md }] };
1041
1063
  });
1042
- server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live for most platforms, so expect a few seconds of latency; X results may come from a snapshot up to 24h old (X bills per returned post) — the snapshot refreshes right after the user publishes to X through OmniSocials. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
1064
+ 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; TikTok: average_time_watched/full_video_watched_rate/total_time_watched/favorites/reach when the workspace enabled TikTok comments). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live for most platforms, so expect a few seconds of latency; X results may come from a snapshot up to 24h old (X bills per returned post) — the snapshot refreshes right after the user publishes to X through OmniSocials. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
1043
1065
  limit: z
1044
1066
  .string()
1045
1067
  .optional()
package/build/types.d.ts CHANGED
@@ -103,6 +103,17 @@ export interface ApiInstagramOptionsOut extends ApiPlatformOptionsOut {
103
103
  thumb_offset?: number | string;
104
104
  cover_url?: string;
105
105
  }
106
+ export interface ApiStorySlide {
107
+ /** Zero-based slide position. */
108
+ index?: number;
109
+ /** The slide's story id on the platform. */
110
+ native_post_id?: string;
111
+ /** Story URL (valid for 24 hours). */
112
+ url?: string | null;
113
+ media_url?: string | null;
114
+ media_type?: "image" | "video" | string | null;
115
+ published_at?: string | null;
116
+ }
106
117
  export interface ApiPost {
107
118
  id: string;
108
119
  /** DB "posted" is mapped to "published" by the serializer. */
@@ -130,6 +141,9 @@ export interface ApiPost {
130
141
  app_url?: string;
131
142
  /** platform → live URL for each platform that successfully published. */
132
143
  published_urls?: Record<string, string>;
144
+ /** Stories only, after publishing: every slide per platform in publish
145
+ * order. `published_urls` keeps only the first slide's URL. */
146
+ story_slides?: Record<string, ApiStorySlide[]>;
133
147
  /** Set on a retry post: the original failed post's id. */
134
148
  retry_of?: string;
135
149
  /** Set on the original post: ids of its retry posts. */
@@ -447,18 +461,32 @@ export interface BestTimesResult {
447
461
  metric?: string;
448
462
  window_days?: number;
449
463
  sample_size?: number;
450
- basis?: "own_data" | "defaults";
464
+ basis?: "own_data" | "own_data_and_audience" | "audience" | "defaults";
465
+ /** Hour (0-23, response timezone) -> followers online. */
466
+ audience_online?: Record<string, number>;
467
+ audience_online_date?: string;
451
468
  posts_needed?: number;
452
469
  /** Absent on the defaults basis. */
453
470
  grid?: BestTimeGridCell[];
454
471
  recommendations?: BestTimeRecommendation[];
455
472
  }
473
+ export interface PostAnalyticsStorySlide {
474
+ /** Zero-based slide position. */
475
+ index: number;
476
+ /** The slide's own story id on the platform. */
477
+ platform_post_id: string;
478
+ metrics: Record<string, number | string>;
479
+ collected_at: string;
480
+ }
456
481
  export interface PostAnalyticsPlatformEntry {
457
482
  platform: string;
458
483
  platform_post_id: string;
459
484
  metrics: Record<string, number | string>;
460
485
  thread_parts: number;
461
486
  collected_at: string;
487
+ /** Multi-slide stories only: per-slide metrics in publish order (the
488
+ * parent `metrics` is the sum across slides). */
489
+ story_slides?: PostAnalyticsStorySlide[];
462
490
  }
463
491
  export interface PostAnalytics {
464
492
  post_id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.24.0",
3
+ "version": "1.27.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",