@omnisocials/mcp-server 1.34.0 → 1.35.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
@@ -227,6 +227,10 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
227
227
 
228
228
  ## Changelog
229
229
 
230
+ ### 1.34.1 (2026-09-14)
231
+
232
+ - **Changed: Threads threads and the Threads inbox are no longer "rolling out".** Meta approved the reply, read-replies and mentions permissions, so `threads.thread_parts` and the Threads inbox work for every Threads connection; accounts connected before 2026-09-14 need a one-time reconnect under Settings -> Organisation -> Workspaces (`400`/`401` errors name the reconnect). Threads location tagging stays rolling out (not approved yet). Same on the companion server.
233
+
230
234
  ### 1.34.0 (2026-09-06)
231
235
 
232
236
  - **Added: work through the inbox one item at a time.** New tool `get_next_unanswered` returns the next inbox item that still needs an answer, with the whole conversation and the post it is on, in one call: the customer's latest DM with no reply after it (Instagram/Facebook DMs within the 24-hour reply window), or a comment/mention nobody replied to and that is not hidden. Longest-waiting first; filter with `platform` and `type`; only unread items by default (`mark_inbox_read` is the durable skip, `include_read: true` widens it, `exclude` skips up to 100 conversation ids for one call); looks back 30 days and reports `remaining`. `reply_to_inbox` accepts `include_next: true` and returns the next item in the same response. `list_inbox_conversations` gains an `unanswered` filter. Replies typed in the native apps now appear as outgoing messages and count as answers. Same on the companion server (`mcp.omnisocials.com`).
package/build/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
1
+ import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
2
2
  export declare function fetchImageAsBase64(url: string): Promise<{
3
3
  data: string;
4
4
  mimeType: string;
@@ -168,6 +168,8 @@ export interface CreatePostInput {
168
168
  y: number;
169
169
  image_index?: number;
170
170
  }>;
171
+ /** Video thumbnail: { type: "frame"|"custom", thumb_offset?, cover_url?, overrides? }. */
172
+ video_cover?: ApiVideoCover;
171
173
  pinterest?: Record<string, unknown>;
172
174
  youtube?: Record<string, unknown>;
173
175
  instagram?: Record<string, unknown>;
@@ -248,6 +250,7 @@ export declare class OmniSocialsClient {
248
250
  y: number;
249
251
  image_index?: number;
250
252
  }>;
253
+ video_cover?: ApiVideoCover | null;
251
254
  pinterest?: Record<string, unknown>;
252
255
  youtube?: Record<string, unknown>;
253
256
  instagram?: Record<string, unknown>;
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.34.0",
32
+ version: "1.35.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,8 +16,14 @@ 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
+ /** Unique accounts reached; null when the platform did not report it. */
20
+ export declare function getReach(platform: string, metrics: Metrics): number | null;
21
+ /** Post-level metrics a platform can never report: platform -> metric key -> reason. */
22
+ export declare const UNSUPPORTED_POST_METRICS: Record<string, Record<string, string>>;
23
+ /** Reason `metricKey` is unavailable on `platform`, or null when it is available. */
24
+ export declare function unsupportedMetricReason(platform: string, metricKey: string): string | null;
25
+ /** True when the platform can never report `metricKey`; a finite stored value overrides the map. */
26
+ export declare function isMetricUnsupported(platform: string, metricKey: string, metrics?: Metrics): boolean;
21
27
  /**
22
28
  * Engagement rate as a percentage (0-100), suppressed below the impression
23
29
  * floor and capped at 100%.
package/build/metrics.js CHANGED
@@ -106,10 +106,101 @@ export function getImpressions(platform, metrics) {
106
106
  return num(m.views) || num(m.impressions) || num(m.reach);
107
107
  }
108
108
  }
109
- /** Unique accounts reached, when the platform reports it (0 otherwise). */
109
+ // Finite number or null: a missing key stays "no data", never 0.
110
+ const finiteOrNull = (v) => {
111
+ if (v === null || v === undefined || v === "")
112
+ return null;
113
+ const n = typeof v === "number" ? v : Number(v);
114
+ return Number.isFinite(n) ? n : null;
115
+ };
116
+ /** Unique accounts reached; null when the platform did not report it. */
110
117
  export function getReach(platform, metrics) {
111
118
  const m = metrics || {};
112
- return num(m.reach) || num(m.unique_impressions) || num(m.members_reached);
119
+ for (const key of ["reach", "unique_impressions", "members_reached"]) {
120
+ const v = finiteOrNull(m[key]);
121
+ if (v !== null)
122
+ return v;
123
+ }
124
+ return null;
125
+ }
126
+ /** Post-level metrics a platform can never report: platform -> metric key -> reason. */
127
+ export const UNSUPPORTED_POST_METRICS = {
128
+ x: {
129
+ reach: "X does not report reach, only impressions.",
130
+ },
131
+ facebook: {
132
+ saves: "Facebook does not report saves.",
133
+ },
134
+ instagram: {
135
+ clicks: "Instagram reports link clicks for stories only (link sticker taps).",
136
+ link_clicks: "Instagram reports link clicks for stories only (link sticker taps).",
137
+ },
138
+ linkedin_page: {
139
+ link_clicks: "LinkedIn Pages report total clicks only, not link clicks separately.",
140
+ saves: "LinkedIn Pages do not report saves.",
141
+ },
142
+ threads: {
143
+ reach: "Threads does not report reach for posts.",
144
+ clicks: "Threads reports link clicks at account level only, not per post.",
145
+ link_clicks: "Threads reports link clicks at account level only, not per post.",
146
+ saves: "Threads does not report saves.",
147
+ },
148
+ youtube: {
149
+ reach: "YouTube does not report reach, only views.",
150
+ clicks: "YouTube does not report clicks.",
151
+ link_clicks: "YouTube does not report clicks.",
152
+ saves: "YouTube does not report saves.",
153
+ },
154
+ tiktok: {
155
+ clicks: "TikTok reports bio link clicks at account level only, not per video.",
156
+ link_clicks: "TikTok reports bio link clicks at account level only, not per video.",
157
+ },
158
+ pinterest: {
159
+ reach: "Pinterest does not report reach, only impressions.",
160
+ shares: "Pinterest does not report shares.",
161
+ },
162
+ bluesky: {
163
+ impressions: "Bluesky does not report impressions or views.",
164
+ reach: "Bluesky does not report reach.",
165
+ clicks: "Bluesky does not report clicks.",
166
+ link_clicks: "Bluesky does not report clicks.",
167
+ video_views: "Bluesky does not report video views.",
168
+ engagement_rate: "Engagement rate needs impressions, which Bluesky does not report.",
169
+ },
170
+ mastodon: {
171
+ impressions: "Mastodon does not report impressions or views.",
172
+ reach: "Mastodon does not report reach.",
173
+ clicks: "Mastodon does not report clicks.",
174
+ link_clicks: "Mastodon does not report clicks.",
175
+ saves: "Mastodon does not report saves.",
176
+ video_views: "Mastodon does not report video views.",
177
+ engagement_rate: "Engagement rate needs impressions, which Mastodon does not report.",
178
+ },
179
+ google_business: {
180
+ impressions: "Google discontinued per-post metrics for Business Profiles in 2023.",
181
+ reach: "Google discontinued per-post metrics for Business Profiles in 2023.",
182
+ likes: "Google discontinued per-post metrics for Business Profiles in 2023.",
183
+ comments: "Google discontinued per-post metrics for Business Profiles in 2023.",
184
+ shares: "Google discontinued per-post metrics for Business Profiles in 2023.",
185
+ saves: "Google discontinued per-post metrics for Business Profiles in 2023.",
186
+ clicks: "Google discontinued per-post metrics for Business Profiles in 2023.",
187
+ link_clicks: "Google discontinued per-post metrics for Business Profiles in 2023.",
188
+ video_views: "Google discontinued per-post metrics for Business Profiles in 2023.",
189
+ engagement: "Google discontinued per-post metrics for Business Profiles in 2023.",
190
+ engagement_rate: "Google discontinued per-post metrics for Business Profiles in 2023.",
191
+ },
192
+ };
193
+ /** Reason `metricKey` is unavailable on `platform`, or null when it is available. */
194
+ export function unsupportedMetricReason(platform, metricKey) {
195
+ return UNSUPPORTED_POST_METRICS[platform]?.[metricKey] ?? null;
196
+ }
197
+ /** True when the platform can never report `metricKey`; a finite stored value overrides the map. */
198
+ export function isMetricUnsupported(platform, metricKey, metrics) {
199
+ if (!unsupportedMetricReason(platform, metricKey))
200
+ return false;
201
+ if (metrics && finiteOrNull(metrics[metricKey]) !== null)
202
+ return false;
203
+ return true;
113
204
  }
114
205
  /**
115
206
  * Engagement rate as a percentage (0-100), suppressed below the impression
@@ -2,7 +2,7 @@ import { z } from "zod";
2
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. 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).", {
5
+ server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky and Mastodon; link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. 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
  }, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
@@ -295,16 +295,19 @@ export function registerInboxTools(server, getClient) {
295
295
  if (result.error)
296
296
  return errorResult(result.error);
297
297
  const m = result.data;
298
+ const text = result.note
299
+ ? `Comment \`#${m?.id ?? message_id}\` on ${m?.platform || "the platform"}: ${result.note} Marked as handled — no need to retry it.`
300
+ : `Comment \`#${m?.id ?? message_id}\` on ${m?.platform || "the platform"} is now ${m?.hidden ? "hidden" : "visible"}.`;
298
301
  return {
299
302
  content: [
300
303
  {
301
304
  type: "text",
302
- text: `Comment \`#${m?.id ?? message_id}\` on ${m?.platform || "the platform"} is now ${m?.hidden ? "hidden" : "visible"}.`,
305
+ text,
303
306
  },
304
307
  ],
305
308
  };
306
309
  };
307
- server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
310
+ server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
308
311
  // Older name from 1.28.0 (when hiding was Threads-only). Kept so existing
309
312
  // setups keep working; same call, same platforms as hide_inbox_comment.
310
313
  server.tool("hide_inbox_reply", "Older name for hide_inbox_comment, kept so existing setups keep working; prefer hide_inbox_comment. Same behaviour: hide (or unhide with hide: false) a comment someone left on one of the workspace's posts, as the post owner, on Facebook, Instagram, TikTok, YouTube or Threads. Takes the message id (the #id in the thread), not the conversation id. A 403 reconnect_required means the account must be reconnected in the dashboard to grant the comment-moderation permission.", hideCommentSchema, { title: "Hide Inbox Comment (older name)", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
@@ -60,9 +60,35 @@ const FACEBOOK_OPTIONS = z
60
60
  .max(8000)
61
61
  .optional()
62
62
  .describe("Text auto-posted as the first comment on the Facebook post right after it publishes. Page posts only (the API cannot comment on personal-profile posts). Not posted for Stories."),
63
+ thumbnail_type: z
64
+ .enum(["from-video", "from-library"])
65
+ .nullable()
66
+ .optional()
67
+ .describe("Video thumbnail for Facebook feed videos and reels: 'from-video' uses the frame at thumb_offset, 'from-library' uploads the image at cover_url. Applied after the video is live; a thumbnail failure never fails the post. On update_post, null removes the Facebook override."),
68
+ thumb_offset: z
69
+ .number()
70
+ .optional()
71
+ .describe("Frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
72
+ cover_url: z
73
+ .string()
74
+ .optional()
75
+ .describe("Custom thumbnail image URL (JPEG/PNG, max 10 MB). Used with thumbnail_type 'from-library'."),
63
76
  })
64
77
  .optional()
65
78
  .describe("Facebook options");
79
+ const VIDEO_COVER_ENTRY = z.object({
80
+ type: z.enum(["frame", "custom"]).describe("'frame' uses the video frame at thumb_offset; 'custom' uploads the image at cover_url."),
81
+ thumb_offset: z.number().int().min(0).optional().describe("Milliseconds into the video (type 'frame'). 3000 = 0:03."),
82
+ cover_url: z.string().optional().describe("Public image URL, JPEG or PNG (type 'custom')."),
83
+ });
84
+ const VIDEO_COVER_OPTIONS = VIDEO_COVER_ENTRY.extend({
85
+ overrides: z
86
+ .record(z.string(), VIDEO_COVER_ENTRY.nullable())
87
+ .optional()
88
+ .describe("Per-platform overrides keyed by platform id (instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube). An override wins over the base cover for that platform."),
89
+ })
90
+ .optional()
91
+ .describe("Video thumbnail for a post whose media is ONE video. Applied on Instagram (feed videos and reels), Facebook (feed videos and reels), LinkedIn Profile and Page, TikTok, Pinterest and YouTube Shorts. TikTok only takes a frame: a 'custom' cover is skipped there, so add a tiktok override with type 'frame' when the user wants a specific TikTok frame. The per-platform reel fields (instagram.thumb_offset / cover_url, tiktok.video_cover_timestamp_ms, pinterest.video_cover) keep working and win over the base cover for their platform. get_post reads it back as `video_cover`.");
66
92
  const LINKEDIN_OPTIONS = z
67
93
  .object({
68
94
  first_comment: z
@@ -601,6 +627,36 @@ export function registerPostTools(server, getClient) {
601
627
  optionRows.push(["Pinterest title", pin.title]);
602
628
  if (typeof pin.link === "string" && pin.link)
603
629
  optionRows.push(["Pinterest link", pin.link]);
630
+ // Cross-platform video cover (base + per-platform overrides).
631
+ const describeCover = (c) => {
632
+ if (!c || typeof c !== "object")
633
+ return null;
634
+ const entry = c;
635
+ if (entry.type === "frame") {
636
+ const ms = Number(entry.thumb_offset) || 0;
637
+ return `video frame at ${ms} ms (${(ms / 1000).toFixed(1)}s into the video)`;
638
+ }
639
+ if (entry.type === "custom" && typeof entry.cover_url === "string")
640
+ return `custom image ${entry.cover_url}`;
641
+ return null;
642
+ };
643
+ const vc = p.video_cover;
644
+ const baseCover = describeCover(vc);
645
+ if (baseCover)
646
+ optionRows.push(["Video cover", baseCover]);
647
+ const coverOverrides = vc && typeof vc === "object" ? vc.overrides : null;
648
+ if (coverOverrides && typeof coverOverrides === "object") {
649
+ for (const [platform, entry] of Object.entries(coverOverrides)) {
650
+ const text = describeCover(entry);
651
+ if (text)
652
+ optionRows.push([`Video cover (${platform})`, text]);
653
+ }
654
+ }
655
+ const fbCoverMs = Number(p.facebook?.thumb_offset) || 0;
656
+ const fbCoverUrl = p.facebook?.cover_url;
657
+ if (!coverOverrides?.facebook && (fbCoverMs > 0 || typeof fbCoverUrl === "string")) {
658
+ optionRows.push(["Facebook cover", typeof fbCoverUrl === "string" ? `custom image ${fbCoverUrl}` : `video frame at ${fbCoverMs} ms`]);
659
+ }
604
660
  if (optionRows.length) {
605
661
  md += `\n\n### Platform Options\n\n| Option | Value |\n|--------|-------|\n`;
606
662
  for (const [k, v] of optionRows)
@@ -714,6 +770,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
714
770
  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."),
715
771
  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."),
716
772
  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."),
773
+ video_cover: VIDEO_COVER_OPTIONS,
717
774
  pinterest: PINTEREST_OPTIONS,
718
775
  youtube: z.object({
719
776
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
@@ -756,7 +813,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
756
813
  thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
757
814
  }).optional().describe("Mastodon options"),
758
815
  threads: z.object({
759
- location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (disabled in this environment). On update_post, pass null to remove the tag."),
816
+ location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
760
817
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread instead of a single post. Provide 2 to 25 parts; parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids (from upload_media) or media_urls. When set, the Threads caption is taken from part 1 and content is ignored for Threads. For a single post, omit thread_parts and use content."),
761
818
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
762
819
  google_business: GOOGLE_BUSINESS_OPTIONS,
@@ -871,6 +928,7 @@ Do NOT call without required media — it will fail.`, {
871
928
  hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. Tags are merged in once at create time; tags already in a caption are skipped."),
872
929
  hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("caption_append (default) appends tags to the captions; first_comment posts them as the auto first comment on comment-capable platforms (others fall back to caption)."),
873
930
  hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
931
+ video_cover: VIDEO_COVER_OPTIONS,
874
932
  pinterest: PINTEREST_OPTIONS,
875
933
  youtube: z.object({
876
934
  title: z.string().optional().describe("Short title shown on YouTube."),
@@ -913,7 +971,7 @@ Do NOT call without required media — it will fail.`, {
913
971
  thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
914
972
  }).optional().describe("Mastodon options"),
915
973
  threads: z.object({
916
- location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (disabled in this environment). On update_post, pass null to remove the tag."),
974
+ location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
917
975
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread (2 to 25 parts). Parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids or media_urls. The Threads caption is taken from part 1."),
918
976
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
919
977
  google_business: GOOGLE_BUSINESS_OPTIONS,
@@ -985,6 +1043,7 @@ Do NOT call without required media — it will fail.`, {
985
1043
  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."),
986
1044
  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."),
987
1045
  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). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
1046
+ video_cover: VIDEO_COVER_OPTIONS.nullable().describe("Replaces the stored video cover wholesale; pass null to remove it; omit to leave it untouched. facebook.thumbnail_type / thumb_offset / cover_url merge into overrides.facebook on their own."),
988
1047
  youtube: z.object({
989
1048
  title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
990
1049
  tags: z.array(z.string()).optional(),
@@ -1027,7 +1086,7 @@ Do NOT call without required media — it will fail.`, {
1027
1086
  thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
1028
1087
  }).optional().describe("Mastodon options"),
1029
1088
  threads: z.object({
1030
- location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (disabled in this environment). On update_post, pass null to remove the tag."),
1089
+ location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
1031
1090
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Threads (Meta) thread shape on this post. Pass an array (2 to 25 parts, 500 characters per part) to update/create the thread (attach up to 10 media to any part via media_ids or media_urls; images and videos can be mixed), or `null` to revert to single-post mode."),
1032
1091
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
1033
1092
  google_business: GOOGLE_BUSINESS_OPTIONS,
@@ -1059,7 +1118,7 @@ Do NOT call without required media — it will fail.`, {
1059
1118
  content: [{ type: "text", text: md }],
1060
1119
  };
1061
1120
  });
1062
- server.tool("delete_post", "Delete a post by ID. This action cannot be undone.", {
1121
+ server.tool("delete_post", "Delete a post by ID from OmniSocials only: it leaves the calendar, the posts list and the analytics. This never deletes the live post on a platform, so a published post stays live everywhere it went; the user must delete it in the platform's own app or via \"Delete from [platform]\" in the OmniSocials dashboard. This action cannot be undone.", {
1063
1122
  id: z.string().describe("The post ID to delete"),
1064
1123
  }, { title: "Delete Post", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
1065
1124
  const result = await getClient().deletePost(id);
@@ -1223,7 +1282,7 @@ Flow (Threads): same, but pass the \`id\` as \`threads.location_id\`. Threads ca
1223
1282
  Notes:
1224
1283
  - Use a SPECIFIC venue name. Searching a broad brand ("Starbucks") returns individual store locations, not the brand page.
1225
1284
  - Instagram: if the user already gives you a numeric Facebook Place ID, you can pass it straight to create_post as \`location_id\`; it's validated at publish time and an invalid one returns a clear error. If results come back empty with a permission note, the workspace's Facebook app can't search arbitrary places; tell the user to use their own business Page's ID.
1226
- - Threads: needs the workspace's Threads connection to have the location permission; if the result says to reconnect Threads, tell the user to reconnect it under Settings → Organisation → Workspaces. Threads location tagging is currently rolling out, so the search can also answer that it is not available yet.`, {
1285
+ - Threads: needs the workspace's Threads connection to have the location permission; if the result says to reconnect Threads, tell the user to reconnect it under Settings → Organisation → Workspaces. Threads location tagging is currently rolling out (Meta has not approved the permission yet), so the search can also answer that it is not available yet.`, {
1227
1286
  query: z
1228
1287
  .string()
1229
1288
  .optional()
package/build/types.d.ts CHANGED
@@ -21,6 +21,7 @@ export interface ApiResponse<T = unknown> {
21
21
  marked_read?: number;
22
22
  next?: InboxNextUnanswered | null;
23
23
  remaining?: number;
24
+ note?: string;
24
25
  }
25
26
  export interface InboxParticipant {
26
27
  id: string;
@@ -137,6 +138,21 @@ export interface ApiInstagramOptionsOut extends ApiPlatformOptionsOut {
137
138
  thumb_offset?: number | string;
138
139
  cover_url?: string;
139
140
  }
141
+ /** One video-cover choice: a frame offset or a custom image. */
142
+ export interface ApiVideoCoverEntry {
143
+ type: "frame" | "custom";
144
+ /** Milliseconds into the video (type "frame"). */
145
+ thumb_offset?: number;
146
+ /** Public image URL (type "custom"). */
147
+ cover_url?: string;
148
+ }
149
+ /** Cross-platform video cover as stored on the post (`video_cover`). On
150
+ * input, a `null` override entry drops that platform's override; the API
151
+ * never echoes nulls back. */
152
+ export interface ApiVideoCover extends ApiVideoCoverEntry {
153
+ /** Per-platform overrides keyed by platform id; an override wins for that platform. */
154
+ overrides?: Record<string, ApiVideoCoverEntry | null>;
155
+ }
140
156
  export interface ApiStorySlide {
141
157
  /** Zero-based slide position. */
142
158
  index?: number;
@@ -178,6 +194,8 @@ export interface ApiPost {
178
194
  /** Stories only, after publishing: every slide per platform in publish
179
195
  * order. `published_urls` keeps only the first slide's URL. */
180
196
  story_slides?: Record<string, ApiStorySlide[]>;
197
+ /** Video thumbnail (base + per-platform overrides); null when none was chosen. */
198
+ video_cover?: ApiVideoCover | null;
181
199
  /** Set on a retry post: the original failed post's id. */
182
200
  retry_of?: string;
183
201
  /** Set on the original post: ids of its retry posts. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "mcpName": "io.github.omnisocials/mcp-server",
5
5
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
6
6
  "type": "module",