@omnisocials/mcp-server 1.24.0 → 1.28.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
@@ -108,10 +108,10 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
108
108
  | `youtube` | YouTube | Shorts |
109
109
  | `tiktok` | TikTok | Posts and reels |
110
110
  | `pinterest` | Pinterest | Pins (requires `board_id`) |
111
- | `x` | X (Twitter) | Posts |
112
- | `threads` | Threads | Posts |
113
- | `bluesky` | Bluesky | Posts |
114
- | `mastodon` | Mastodon | Posts |
111
+ | `x` | X (Twitter) | Posts, chained threads via `x.thread_parts` |
112
+ | `threads` | Threads | Posts, chained threads via `threads.thread_parts` |
113
+ | `bluesky` | Bluesky | Posts, chained threads via `bluesky.thread_parts` |
114
+ | `mastodon` | Mastodon | Posts, chained threads via `mastodon.thread_parts` |
115
115
 
116
116
  ## Available Tools
117
117
 
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,
@@ -100,6 +103,28 @@ export interface MastodonPostOptions {
100
103
  export interface MastodonPostOptionsUpdate {
101
104
  thread_parts?: MastodonThreadPartInput[] | null;
102
105
  }
106
+ export interface ThreadsThreadPartInput {
107
+ /** Part text, 1 to 500 characters. */
108
+ text: string;
109
+ /** Optional per-part media as Library IDs from upload_media (max 10,
110
+ * images and/or videos). Entries may be `{ id, alt }`. */
111
+ media_ids?: MediaIdEntry[];
112
+ /** Optional per-part media as external URLs (max 10, images and/or
113
+ * videos). Entries may be `{ url, alt }`. */
114
+ media_urls?: MediaUrlEntry[];
115
+ }
116
+ export interface ThreadsPostOptions {
117
+ /** Provide 2 to 25 parts to publish as a thread. Omit for a single post.
118
+ * When set, the Threads caption is taken from part 1. */
119
+ thread_parts?: ThreadsThreadPartInput[];
120
+ }
121
+ /**
122
+ * Update-side variant: `thread_parts: null` clears the thread (revert to a
123
+ * single post); `undefined` leaves the existing thread untouched.
124
+ */
125
+ export interface ThreadsPostOptionsUpdate {
126
+ thread_parts?: ThreadsThreadPartInput[] | null;
127
+ }
103
128
  /** Non-sponsored LinkedIn poll: question + 2-4 options + duration. */
104
129
  export interface LinkedInPollFields {
105
130
  question: string;
@@ -146,6 +171,7 @@ export interface CreatePostInput {
146
171
  x?: XPostOptions;
147
172
  bluesky?: BlueskyPostOptions;
148
173
  mastodon?: MastodonPostOptions;
174
+ threads?: ThreadsPostOptions;
149
175
  google_business?: Record<string, unknown>;
150
176
  hashtag_set?: string;
151
177
  hashtag_placement?: "caption_append" | "first_comment";
@@ -181,6 +207,10 @@ export declare class OmniSocialsClient {
181
207
  media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
182
208
  media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
183
209
  type?: string;
210
+ link_url?: string;
211
+ link_title?: string;
212
+ link_description?: string;
213
+ link_thumbnail_url?: string;
184
214
  location_id?: string;
185
215
  collaborators?: string[];
186
216
  user_tags?: Array<{
@@ -200,6 +230,7 @@ export declare class OmniSocialsClient {
200
230
  x?: XPostOptionsUpdate;
201
231
  bluesky?: BlueskyPostOptionsUpdate;
202
232
  mastodon?: MastodonPostOptionsUpdate;
233
+ threads?: ThreadsPostOptionsUpdate;
203
234
  google_business?: Record<string, unknown>;
204
235
  }): Promise<ApiResponse<ApiPost>>;
205
236
  deletePost(id: string): Promise<ApiResponse<unknown>>;
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.28.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,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { fetchImageAsBase64, capitalize } from "../client.js";
3
3
  export function registerAccountTools(server, getClient) {
4
- server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
4
+ server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, { title: "List Connected Accounts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
5
5
  const result = await getClient().listAccounts();
6
6
  if (result.error) {
7
7
  return {
@@ -63,7 +63,7 @@ export function registerAccountTools(server, getClient) {
63
63
  });
64
64
  server.tool("get_account", "Get details of a specific connected social media account.", {
65
65
  id: z.string().describe("The account ID"),
66
- }, async ({ id }) => {
66
+ }, { title: "Get Account Details", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
67
67
  const result = await getClient().getAccount(id);
68
68
  if (result.error) {
69
69
  return {
@@ -1,10 +1,10 @@
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
- }, async ({ post_id }) => {
7
+ }, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
9
9
  if (result.error) {
10
10
  return {
@@ -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 }],
@@ -100,7 +118,7 @@ export function registerAnalyticsTools(server, getClient) {
100
118
  .min(1)
101
119
  .max(100)
102
120
  .describe("Numeric OmniSocials post ids (the `id` field from list_posts), up to 100. NOT platform post ids such as YouTube video ids or tweet ids."),
103
- }, async ({ post_ids }) => {
121
+ }, { title: "Get Bulk Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_ids }) => {
104
122
  const result = await getClient().getPostsAnalytics(post_ids);
105
123
  if (result.error) {
106
124
  return {
@@ -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
  }
@@ -159,7 +177,7 @@ export function registerAnalyticsTools(server, getClient) {
159
177
  period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
160
178
  start_date: z.string().optional().describe('Custom start date. Accepts "YYYY-MM-DD" (e.g. "2026-04-01") or "YYYY-MM" month-shorthand (e.g. "2026-04" → first of the month).'),
161
179
  end_date: z.string().optional().describe('Custom end date. Same formats as start_date; "YYYY-MM" expands to the last day of the month.'),
162
- }, async (params) => {
180
+ }, { title: "Get Analytics Overview", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
163
181
  const result = await getClient().getAnalyticsOverview(params);
164
182
  if (result.error) {
165
183
  return {
@@ -205,10 +223,10 @@ 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
- }, async (params) => {
229
+ }, { title: "Get Account Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
212
230
  const result = await getClient().getAccountAnalytics(params);
213
231
  if (result.error) {
214
232
  return {
@@ -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,10 +309,10 @@ 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
- }, async (params) => {
315
+ }, { title: "Get Best Times to Post", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
276
316
  const result = await getClient().getBestTimes(params);
277
317
  if (result.error) {
278
318
  return {
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export function registerHashtagSetTools(server, getClient) {
3
- server.tool("list_hashtag_sets", "List the saved hashtag sets in this workspace. Apply one to a new post by passing its name via create_post (hashtag_set) — the tags are appended to the captions or, with hashtag_placement='first_comment', posted as the auto first comment.", {}, async () => {
3
+ server.tool("list_hashtag_sets", "List the saved hashtag sets in this workspace. Apply one to a new post by passing its name via create_post (hashtag_set) — the tags are appended to the captions or, with hashtag_placement='first_comment', posted as the auto first comment.", {}, { title: "List Hashtag Sets", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
4
4
  const result = await getClient().listHashtagSets();
5
5
  if (result.error) {
6
6
  return {
@@ -28,7 +28,7 @@ export function registerHashtagSetTools(server, getClient) {
28
28
  server.tool("create_hashtag_set", "Save a reusable, named group of hashtags (e.g. 'Fitness Brand' -> #fitness #gym #workout). Tags may include or omit the leading '#'; they are deduped case-insensitively and kept in order (max 100). Then apply the set to any new post via create_post (hashtag_set).", {
29
29
  name: z.string().describe("Set name, unique per workspace, e.g. 'Fitness Brand'"),
30
30
  hashtags: z.array(z.string()).describe('Tags in order, with or without the leading "#", e.g. ["fitness", "#gym", "workout"]'),
31
- }, async (params) => {
31
+ }, { title: "Create Hashtag Set", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
32
32
  const result = await getClient().createHashtagSet(params);
33
33
  if (result.error) {
34
34
  return {
@@ -52,7 +52,7 @@ export function registerHashtagSetTools(server, getClient) {
52
52
  set_id: z.string().describe("The hashtag set id (from list_hashtag_sets)"),
53
53
  name: z.string().optional().describe("New name for the set"),
54
54
  hashtags: z.array(z.string()).optional().describe("Full replacement tag list, in order"),
55
- }, async ({ set_id, name, hashtags }) => {
55
+ }, { title: "Update Hashtag Set", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ set_id, name, hashtags }) => {
56
56
  const result = await getClient().updateHashtagSet(set_id, { name, hashtags });
57
57
  if (result.error) {
58
58
  return {
@@ -74,7 +74,7 @@ export function registerHashtagSetTools(server, getClient) {
74
74
  });
75
75
  server.tool("delete_hashtag_set", "Delete a saved hashtag set. Posts that already used it keep their hashtags — the tags were merged into their captions at create time.", {
76
76
  set_id: z.string().describe("The hashtag set id (from list_hashtag_sets)"),
77
- }, async ({ set_id }) => {
77
+ }, { title: "Delete Hashtag Set", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ set_id }) => {
78
78
  const result = await getClient().deleteHashtagSet(set_id);
79
79
  if (result.error) {
80
80
  return {
@@ -29,7 +29,7 @@ export function registerInboxTools(server, getClient) {
29
29
  .string()
30
30
  .optional()
31
31
  .describe("Pagination cursor from a previous response"),
32
- }, async (params) => {
32
+ }, { title: "List Inbox Conversations", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
33
33
  const query = {};
34
34
  if (params.platform)
35
35
  query.platform = params.platform;
@@ -78,7 +78,7 @@ export function registerInboxTools(server, getClient) {
78
78
  .optional()
79
79
  .describe("Max messages per page (1-100, default 50)"),
80
80
  cursor: z.string().optional().describe("Pagination cursor"),
81
- }, async ({ conversation_id, limit, cursor }) => {
81
+ }, { title: "Get Inbox Conversation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ conversation_id, limit, cursor }) => {
82
82
  const query = {};
83
83
  if (limit !== undefined)
84
84
  query.limit = String(limit);
@@ -117,7 +117,7 @@ export function registerInboxTools(server, getClient) {
117
117
  });
118
118
  server.tool("mark_inbox_read", "Mark all incoming messages in a conversation as read.", {
119
119
  conversation_id: z.string().describe("The conversation ID to mark read"),
120
- }, async ({ conversation_id }) => {
120
+ }, { title: "Mark Inbox Message Read", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ conversation_id }) => {
121
121
  const result = await getClient().markInboxRead(conversation_id);
122
122
  if (result.error) {
123
123
  return {
@@ -143,7 +143,7 @@ export function registerInboxTools(server, getClient) {
143
143
  text: z
144
144
  .string()
145
145
  .describe("The reply text (max 2000 characters; TikTok comments max 150)"),
146
- }, async ({ conversation_id, text }) => {
146
+ }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ conversation_id, text }) => {
147
147
  const result = await getClient().replyToInboxConversation(conversation_id, {
148
148
  text,
149
149
  });
@@ -6,7 +6,7 @@ export function registerMediaTools(server, getClient) {
6
6
  offset: z.string().optional().describe("Offset for pagination"),
7
7
  search: z.string().optional().describe('Find a file by name or filename, e.g. "play5get50". Use this before uploading to check if the graphic already exists.'),
8
8
  folder_id: z.string().optional().describe('Only return media in this folder id (from list_folders). Use "root" for unfiled items.'),
9
- }, async (params) => {
9
+ }, { title: "List Media Library", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
10
10
  const result = await getClient().listMedia(params);
11
11
  if (result.error) {
12
12
  return {
@@ -72,7 +72,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
72
72
  filename: z.string().optional().describe("Optional filename for the uploaded media"),
73
73
  name: z.string().optional().describe('Human-readable label so you can find this asset by name later instead of re-uploading it, e.g. "pp-play5get50". Strongly recommended on every upload.'),
74
74
  folder: z.string().optional().describe('Optional folder name to file this asset under (created at the top level if it does not exist), e.g. "win-graphics". See list_folders.'),
75
- }, async (params) => {
75
+ }, { title: "Upload Media", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
76
76
  let result;
77
77
  if (params.base64_data && params.mime_type) {
78
78
  result = await getClient().uploadMediaFromBase64({
@@ -147,7 +147,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
147
147
  if (compatibility && compatibility.compatible === false && compatibility.summary) {
148
148
  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.`;
149
149
  }
150
- 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).`;
150
+ md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`, \`bluesky.thread_parts[].media_ids\`, \`mastodon.thread_parts[].media_ids\` and \`threads.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).`;
151
151
  return {
152
152
  content: [{ type: "text", text: md }],
153
153
  };
@@ -159,7 +159,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
159
159
  media_id: z.string().optional().describe("Id of an already-uploaded library item to check."),
160
160
  size_bytes: z.number().optional().describe("File size in bytes (use with mime when you already know them)."),
161
161
  mime: z.string().optional().describe("MIME type, e.g. 'video/mp4' (use with size_bytes)."),
162
- }, async (params) => {
162
+ }, { title: "Check Media Compatibility", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
163
163
  if (!params.url && !params.media_id && !(params.size_bytes && params.mime)) {
164
164
  return {
165
165
  content: [{ type: "text", text: "Error: Provide one of 'url', 'media_id', or 'size_bytes' + 'mime'." }],
@@ -197,7 +197,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
197
197
  id: z.string().describe("The media ID to update"),
198
198
  name: z.string().optional().describe('New human-readable label, e.g. "pp-play5get50". Pass an empty string to clear it.'),
199
199
  folder_id: z.string().optional().describe('Move the file into this folder id (from list_folders). Pass an empty string to move it to the root ("All media").'),
200
- }, async ({ id, name, folder_id }) => {
200
+ }, { title: "Update Media", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, name, folder_id }) => {
201
201
  const body = {};
202
202
  if (name !== undefined)
203
203
  body.name = name;
@@ -224,7 +224,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
224
224
  ],
225
225
  };
226
226
  });
227
- server.tool("list_folders", "List the media-library folders in this workspace (Finder-style organization). Returns each folder's id, name, parent, and item count. Use a folder id with list_media (folder_id) or update_media (folder_id), or a folder name with upload_media (folder).", {}, async () => {
227
+ server.tool("list_folders", "List the media-library folders in this workspace (Finder-style organization). Returns each folder's id, name, parent, and item count. Use a folder id with list_media (folder_id) or update_media (folder_id), or a folder name with upload_media (folder).", {}, { title: "List Media Folders", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
228
228
  const result = await getClient().listFolders();
229
229
  if (result.error) {
230
230
  return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
@@ -243,7 +243,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
243
243
  server.tool("create_folder", "Create a media-library folder. Use it to organize assets (e.g. a 'win-graphics' folder), then upload into it with upload_media (folder) or move files with update_media (folder_id).", {
244
244
  name: z.string().describe("Folder name, e.g. 'win-graphics'"),
245
245
  parent_id: z.string().optional().describe("Optional parent folder id to nest under (from list_folders). Omit for a top-level folder."),
246
- }, async ({ name, parent_id }) => {
246
+ }, { title: "Create Media Folder", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ name, parent_id }) => {
247
247
  const result = await getClient().createFolder({ name, parent_id });
248
248
  if (result.error) {
249
249
  return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
3
3
  // Shared media entry schemas — every media list (flat arrays, per-platform
4
- // records, and x/bluesky/mastodon thread parts) accepts either a plain string
4
+ // records, and x/bluesky/mastodon/threads thread parts) accepts either a plain string
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),
@@ -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."),
@@ -165,6 +170,11 @@ const MASTODON_THREAD_PART = z.object({
165
170
  media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
166
171
  media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
167
172
  });
173
+ const THREADS_THREAD_PART = z.object({
174
+ text: z.string().min(1).max(500).describe("Part text (1 to 500 characters)."),
175
+ media_ids: z.array(mediaIdEntry).max(10).optional().describe("Per-part media as Library IDs from upload_media (max 10 combined with media_urls; images and videos can be mixed in one carousel). Each entry is a plain ID string or { id, alt }."),
176
+ media_urls: z.array(mediaUrlEntry).max(10).optional().describe("Per-part media as external URLs (max 10 combined with media_ids; images and videos can be mixed in one carousel). Each entry is a plain URL string or { url, alt }."),
177
+ });
168
178
  const GOOGLE_BUSINESS_OPTIONS = z.object({
169
179
  topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
170
180
  cta: z.object({
@@ -245,10 +255,10 @@ function postAppUrl(p) {
245
255
  }
246
256
  export function registerPostTools(server, getClient) {
247
257
  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"),
258
+ 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
259
  limit: z.string().optional().describe("Max results to return (default: 20)"),
250
260
  offset: z.string().optional().describe("Offset for pagination"),
251
- }, async (params) => {
261
+ }, { title: "List Posts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
252
262
  const result = await getClient().listPosts(params);
253
263
  if (result.error) {
254
264
  return {
@@ -272,13 +282,28 @@ export function registerPostTools(server, getClient) {
272
282
  ? (contentObj.default || Object.values(contentObj).find((v) => typeof v === "string" && v) || "")
273
283
  : (typeof p.content === "string" ? p.content : "");
274
284
  const hasOverrides = !!contentObj && Object.keys(contentObj).filter((k) => k !== "default" && typeof contentObj[k] === "string" && contentObj[k]).length > 0;
275
- const xParts = p.x?.thread_parts;
276
- const threadParts = Array.isArray(xParts) ? xParts : [];
285
+ // Thread preview: the first thread platform carrying 2+ parts wins
286
+ // (X, Bluesky, Mastodon, Threads share one shape).
287
+ const threadSources = [
288
+ ["X", p.x?.thread_parts],
289
+ ["Bluesky", p.bluesky?.thread_parts],
290
+ ["Mastodon", p.mastodon?.thread_parts],
291
+ ["Threads", p.threads?.thread_parts],
292
+ ];
293
+ let threadLabel = "";
294
+ let threadParts = [];
295
+ for (const [label, parts] of threadSources) {
296
+ if (Array.isArray(parts) && parts.length >= 2) {
297
+ threadLabel = label;
298
+ threadParts = parts;
299
+ break;
300
+ }
301
+ }
277
302
  const isThread = threadParts.length >= 2;
278
303
  let content;
279
304
  if (isThread && !rawContent) {
280
305
  const first = typeof threadParts[0]?.text === "string" ? threadParts[0].text : "";
281
- content = truncate(first, 32) + ` *(X thread, ${threadParts.length} parts)*`;
306
+ content = truncate(first, 32) + ` *(${threadLabel} thread, ${threadParts.length} parts)*`;
282
307
  }
283
308
  else {
284
309
  content = truncate(rawContent, hasOverrides ? 35 : 50) + (hasOverrides ? " *(per-platform)*" : "");
@@ -294,7 +319,7 @@ export function registerPostTools(server, getClient) {
294
319
  });
295
320
  server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs + any per-media alt text), first comment, Instagram collaborators/user tags/location/Trial Reel state/Reel cover (thumbnail_type + thumb_offset in ms), dates and live URLs, enough to fully verify a scheduled post without opening the dashboard. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. A LinkedIn poll is rendered under `### LinkedIn Profile Poll` and/or `### LinkedIn Page Poll` (independent per channel) with the question, options, and duration. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
296
321
  id: z.string().describe("The post ID"),
297
- }, async ({ id }) => {
322
+ }, { title: "Get Post", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
298
323
  const result = await getClient().getPost(id);
299
324
  if (result.error) {
300
325
  return {
@@ -425,6 +450,24 @@ export function registerPostTools(server, getClient) {
425
450
  md += `\n`;
426
451
  }
427
452
  }
453
+ // Threads (Meta) thread: when present, the canonical post text lives here, not in `content`.
454
+ const rawThreadsParts = p.threads?.thread_parts;
455
+ const threadsThreadParts = Array.isArray(rawThreadsParts) ? rawThreadsParts : [];
456
+ if (threadsThreadParts.length >= 2) {
457
+ md += `\n\n### Threads Thread (${threadsThreadParts.length} parts)\n\n`;
458
+ for (let i = 0; i < threadsThreadParts.length; i++) {
459
+ const part = threadsThreadParts[i] || {};
460
+ const text = typeof part.text === "string" ? part.text : "";
461
+ md += `**${i + 1}/${threadsThreadParts.length}.** ${text}\n`;
462
+ if (Array.isArray(part.media_urls) && part.media_urls.length) {
463
+ for (const entry of part.media_urls) {
464
+ const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
465
+ md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
466
+ }
467
+ }
468
+ md += `\n`;
469
+ }
470
+ }
428
471
  // LinkedIn poll(s): independent per channel, mutually exclusive with
429
472
  // media/link-share on that channel, so surface each prominently
430
473
  // rather than leaving callers to infer it's a poll.
@@ -450,6 +493,23 @@ export function registerPostTools(server, getClient) {
450
493
  md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
451
494
  }
452
495
  }
496
+ // Multi-slide stories: one story per slide on the platform. Show every
497
+ // slide's own URL in publish order (published_urls only has slide 1).
498
+ const storySlides = (p.story_slides && typeof p.story_slides === "object" && !Array.isArray(p.story_slides))
499
+ ? p.story_slides
500
+ : {};
501
+ const slideEntries = Object.entries(storySlides).filter(([, list]) => Array.isArray(list) && list.length > 0);
502
+ if (slideEntries.length) {
503
+ md += `\n\n### Story Slides\n\n`;
504
+ for (const [platform, list] of slideEntries) {
505
+ md += `**${capitalize(platform.replace(/_/g, " "))}** (${list.length} slide${list.length === 1 ? "" : "s"})\n`;
506
+ list.forEach((slide, i) => {
507
+ const n = (typeof slide.index === "number" ? slide.index : i) + 1;
508
+ md += `- Slide ${n}${slide.media_type ? ` (${slide.media_type})` : ""}: ${slide.url || slide.native_post_id || "(no url)"}\n`;
509
+ });
510
+ md += `\n`;
511
+ }
512
+ }
453
513
  // Per-platform publish errors (status failed/warning). Point at
454
514
  // retry_post — only the failed platforms are re-published.
455
515
  const postErrors = (p.errors && typeof p.errors === "object" && !Array.isArray(p.errors))
@@ -467,7 +527,7 @@ export function registerPostTools(server, getClient) {
467
527
  }
468
528
  // First comments: nested under each platform object by the API. Show the
469
529
  // configured text plus, once published, the outcome (posted/failed/skipped).
470
- const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube"];
530
+ const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok"];
471
531
  const firstCommentRows = [];
472
532
  for (const platform of firstCommentPlatforms) {
473
533
  const opts = p[platform];
@@ -535,7 +595,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
535
595
  2. **Channels**: Which platforms to post to? Use list_accounts to show available options if needed.
536
596
  3. **Schedule**: When should it be published? (Or save as draft?)
537
597
  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.
598
+ - 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
599
  - 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
600
  - Instagram posts: ALWAYS require at least one image or video.
541
601
  - TikTok posts: ALWAYS require at least one image or video.
@@ -564,6 +624,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
564
624
  - YouTube: Title, privacy status, tags?
565
625
  - TikTok: Privacy level?
566
626
  - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
627
+ - **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
567
628
  - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
568
629
 
569
630
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
@@ -580,7 +641,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
580
641
  z.array(mediaUrlEntry),
581
642
  z.record(z.string(), z.array(mediaUrlEntry)),
582
643
  ]).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)"),
644
+ 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
645
  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
646
  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
647
  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 +650,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
589
650
  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
651
  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
652
  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."),
653
+ 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
654
  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
655
  pinterest: PINTEREST_OPTIONS,
595
656
  youtube: z.object({
@@ -632,8 +693,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
632
693
  mastodon: z.object({
633
694
  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."),
634
695
  }).optional().describe("Mastodon options"),
696
+ threads: z.object({
697
+ 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."),
698
+ }).optional().describe("Threads (Meta) options"),
635
699
  google_business: GOOGLE_BUSINESS_OPTIONS,
636
- }, async (params) => {
700
+ }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
637
701
  const result = await getClient().createPost({ ...params, source: "mcp" });
638
702
  if (result.error) {
639
703
  return {
@@ -702,7 +766,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
702
766
  1. **Content/caption**: What text? If captions differ per platform, use one call with an object: { "default": "fallback", "linkedin": "long", "threads": "short" }.
703
767
  2. **Channels**: Which platforms? Use list_accounts if needed.
704
768
  3. **Media** (REQUIRED for some types — same rules as create_post):
705
- - Stories: ALWAYS need an image or video.
769
+ - 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
770
  - 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
771
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
708
772
  - Pinterest: ALWAYS need an image + board_id.
@@ -710,7 +774,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
710
774
  - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
711
775
  - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** (no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
712
776
  4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
713
- 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
777
+ 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
714
778
  6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
715
779
 
716
780
  Do NOT call without required media — it will fail.`, {
@@ -724,7 +788,11 @@ Do NOT call without required media — it will fail.`, {
724
788
  z.array(mediaUrlEntry),
725
789
  z.record(z.string(), z.array(mediaUrlEntry)),
726
790
  ]).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'"),
791
+ 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'"),
792
+ 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)."),
793
+ 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."),
794
+ link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
795
+ link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
728
796
  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
797
  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
798
  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."),
@@ -772,8 +840,11 @@ Do NOT call without required media — it will fail.`, {
772
840
  mastodon: z.object({
773
841
  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."),
774
842
  }).optional().describe("Mastodon options"),
843
+ threads: z.object({
844
+ 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."),
845
+ }).optional().describe("Threads (Meta) options"),
775
846
  google_business: GOOGLE_BUSINESS_OPTIONS,
776
- }, async (params) => {
847
+ }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
777
848
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
778
849
  if (result.error) {
779
850
  return {
@@ -821,10 +892,10 @@ Do NOT call without required media — it will fail.`, {
821
892
 
822
893
  **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.
823
894
 
824
- **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.`, {
895
+ **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. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
825
896
  id: z.string().describe("The post ID to update"),
826
897
  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)"),
898
+ 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
899
  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
900
  media_ids: z.union([
830
901
  z.array(mediaIdEntry),
@@ -834,6 +905,10 @@ Do NOT call without required media — it will fail.`, {
834
905
  z.array(mediaUrlEntry),
835
906
  z.record(z.string(), z.array(mediaUrlEntry)),
836
907
  ]).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."),
908
+ 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)."),
909
+ 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."),
910
+ link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
911
+ link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
837
912
  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."),
838
913
  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."),
839
914
  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."),
@@ -878,8 +953,11 @@ Do NOT call without required media — it will fail.`, {
878
953
  mastodon: z.object({
879
954
  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."),
880
955
  }).optional().describe("Mastodon options"),
956
+ threads: z.object({
957
+ 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."),
958
+ }).optional().describe("Threads (Meta) options"),
881
959
  google_business: GOOGLE_BUSINESS_OPTIONS,
882
- }, async ({ id, ...data }) => {
960
+ }, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
883
961
  const result = await getClient().updatePost(id, data);
884
962
  if (result.error) {
885
963
  return {
@@ -909,7 +987,7 @@ Do NOT call without required media — it will fail.`, {
909
987
  });
910
988
  server.tool("delete_post", "Delete a post by ID. This action cannot be undone.", {
911
989
  id: z.string().describe("The post ID to delete"),
912
- }, async ({ id }) => {
990
+ }, { title: "Delete Post", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
913
991
  const result = await getClient().deletePost(id);
914
992
  return {
915
993
  content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
@@ -919,7 +997,7 @@ Do NOT call without required media — it will fail.`, {
919
997
 
920
998
  IMPORTANT: Before publishing, verify the post has all required media. If publishing a story or reel, ensure media was attached when the post was created/updated. If it's missing, use update_post to add media first, or inform the user.`, {
921
999
  id: z.string().describe("The post ID to publish"),
922
- }, async ({ id }) => {
1000
+ }, { title: "Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
923
1001
  const result = await getClient().publishPost(id);
924
1002
  if (result.error) {
925
1003
  return {
@@ -945,7 +1023,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
945
1023
 
946
1024
  The retry runs asynchronously (usually within a few minutes). Poll \`get_post\` afterwards: on success the status becomes \`published\` and \`published_urls\` gains the platform's live URL; on another failure the platform's entry reappears under errors. Each platform can be retried at most 3 times — after that, recreate the post. Note \`publish_post\` refuses failed posts; this is the tool for them.`, {
947
1025
  id: z.string().describe("The failed or partially failed post ID to retry"),
948
- }, async ({ id }) => {
1026
+ }, { title: "Retry Failed Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
949
1027
  const result = await getClient().retryPost(id);
950
1028
  if (result.error) {
951
1029
  return {
@@ -982,7 +1060,7 @@ Notes:
982
1060
  query: z
983
1061
  .string()
984
1062
  .describe("Place name to search (min 2 chars) — a dealership, café, venue, etc."),
985
- }, async ({ query }) => {
1063
+ }, { title: "Search Locations", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query }) => {
986
1064
  const result = await getClient().searchLocations(query);
987
1065
  const places = result?.data || [];
988
1066
  if (!places.length) {
@@ -1013,7 +1091,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1013
1091
  .enum(["music", "original_sound"])
1014
1092
  .optional()
1015
1093
  .describe("Catalog to search: licensed music (default) or original sounds created by Instagram users."),
1016
- }, async ({ query, type }) => {
1094
+ }, { title: "Search Instagram Audio", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query, type }) => {
1017
1095
  const result = await getClient().searchInstagramAudio(query, type);
1018
1096
  const tracks = result?.data || [];
1019
1097
  if (!tracks.length) {
@@ -1039,7 +1117,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1039
1117
  });
1040
1118
  return { content: [{ type: "text", text: md }] };
1041
1119
  });
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.", {
1120
+ 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
1121
  limit: z
1044
1122
  .string()
1045
1123
  .optional()
@@ -1048,7 +1126,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
1048
1126
  .string()
1049
1127
  .optional()
1050
1128
  .describe('Optional comma-separated platform filter, e.g. "instagram,tiktok". Defaults to every connected platform.'),
1051
- }, async (params) => {
1129
+ }, { title: "Get Recent Platform Posts", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (params) => {
1052
1130
  const result = await getClient().getRecentPlatformPosts(params);
1053
1131
  if (result.error) {
1054
1132
  return {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { formatDateTime } from "../client.js";
3
3
  export function registerWebhookTools(server, getClient) {
4
- server.tool("list_webhooks", "List all configured webhooks.", {}, async () => {
4
+ server.tool("list_webhooks", "List all configured webhooks.", {}, { title: "List Webhooks", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
5
5
  const result = await getClient().listWebhooks();
6
6
  if (result.error) {
7
7
  return {
@@ -31,7 +31,7 @@ export function registerWebhookTools(server, getClient) {
31
31
  server.tool("create_webhook", "Create a new webhook to receive event notifications. Available events: post.scheduled, post.published, post.failed", {
32
32
  url: z.string().describe("The HTTPS URL to send webhook events to"),
33
33
  events: z.array(z.string()).describe("Events to subscribe to: post.scheduled, post.published, post.failed"),
34
- }, async (params) => {
34
+ }, { title: "Create Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
35
35
  const result = await getClient().createWebhook(params);
36
36
  if (result.error) {
37
37
  return {
@@ -61,7 +61,7 @@ export function registerWebhookTools(server, getClient) {
61
61
  });
62
62
  server.tool("get_webhook", "Get details of a specific webhook by ID.", {
63
63
  id: z.string().describe("The webhook ID"),
64
- }, async ({ id }) => {
64
+ }, { title: "Get Webhook", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
65
65
  const result = await getClient().getWebhook(id);
66
66
  if (result.error) {
67
67
  return {
@@ -97,7 +97,7 @@ export function registerWebhookTools(server, getClient) {
97
97
  url: z.string().optional().describe("Updated HTTPS URL"),
98
98
  events: z.array(z.string()).optional().describe("Updated event list"),
99
99
  is_active: z.boolean().optional().describe("Enable or disable the webhook"),
100
- }, async ({ id, ...data }) => {
100
+ }, { title: "Update Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
101
101
  const result = await getClient().updateWebhook(id, data);
102
102
  if (result.error) {
103
103
  return {
@@ -123,7 +123,7 @@ export function registerWebhookTools(server, getClient) {
123
123
  });
124
124
  server.tool("delete_webhook", "Delete a webhook by ID. You will stop receiving notifications at this URL.", {
125
125
  id: z.string().describe("The webhook ID to delete"),
126
- }, async ({ id }) => {
126
+ }, { title: "Delete Webhook", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
127
127
  const result = await getClient().deleteWebhook(id);
128
128
  return {
129
129
  content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Webhook deleted successfully." }],
@@ -131,7 +131,7 @@ export function registerWebhookTools(server, getClient) {
131
131
  });
132
132
  server.tool("rotate_webhook_secret", "Rotate the signing secret for a webhook. The new secret will only be shown once.", {
133
133
  id: z.string().describe("The webhook ID"),
134
- }, async ({ id }) => {
134
+ }, { title: "Rotate Webhook Secret", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
135
135
  const result = await getClient().rotateWebhookSecret(id);
136
136
  if (result.error) {
137
137
  return {
@@ -60,7 +60,7 @@ function resolveWorkspaceIndex(entries, query) {
60
60
  return { notFound: true };
61
61
  }
62
62
  export function registerWorkspaceTools(server, workspaceClients, sessionState) {
63
- server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, async () => {
63
+ server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, { title: "List Workspaces", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
64
64
  const workspaces = await describeWorkspaces(workspaceClients, sessionState);
65
65
  if (workspaces.length === 1) {
66
66
  const ws = workspaces[0];
@@ -85,7 +85,7 @@ export function registerWorkspaceTools(server, workspaceClients, sessionState) {
85
85
  workspace: z
86
86
  .string()
87
87
  .describe('The workspace to switch to: its name (preferred, e.g. "Daily Edge Sports"), or its id/list number.'),
88
- }, async ({ workspace }) => {
88
+ }, { title: "Switch Workspace", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ workspace }) => {
89
89
  const entries = await describeWorkspaces(workspaceClients, sessionState);
90
90
  const resolved = resolveWorkspaceIndex(entries, workspace);
91
91
  if ("ambiguous" in resolved) {
package/build/types.d.ts CHANGED
@@ -72,7 +72,7 @@ export type ApiPostMediaEntry = string | {
72
72
  alt?: string;
73
73
  [k: string]: unknown;
74
74
  };
75
- /** A thread part as echoed back by the API (X / Bluesky / Mastodon). */
75
+ /** A thread part as echoed back by the API (X / Bluesky / Mastodon / Threads). */
76
76
  export interface ApiThreadPartOut {
77
77
  id?: string;
78
78
  text?: string;
@@ -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. */
@@ -170,6 +184,7 @@ export interface ApiPost {
170
184
  x?: ApiPlatformOptionsOut;
171
185
  bluesky?: ApiPlatformOptionsOut;
172
186
  mastodon?: ApiPlatformOptionsOut;
187
+ threads?: ApiPlatformOptionsOut;
173
188
  /** platform → user-friendly error message; only failed platforms appear.
174
189
  * null while draft/scheduled/processing or when every platform succeeded. */
175
190
  errors?: Record<string, string> | null;
@@ -447,18 +462,32 @@ export interface BestTimesResult {
447
462
  metric?: string;
448
463
  window_days?: number;
449
464
  sample_size?: number;
450
- basis?: "own_data" | "defaults";
465
+ basis?: "own_data" | "own_data_and_audience" | "audience" | "defaults";
466
+ /** Hour (0-23, response timezone) -> followers online. */
467
+ audience_online?: Record<string, number>;
468
+ audience_online_date?: string;
451
469
  posts_needed?: number;
452
470
  /** Absent on the defaults basis. */
453
471
  grid?: BestTimeGridCell[];
454
472
  recommendations?: BestTimeRecommendation[];
455
473
  }
474
+ export interface PostAnalyticsStorySlide {
475
+ /** Zero-based slide position. */
476
+ index: number;
477
+ /** The slide's own story id on the platform. */
478
+ platform_post_id: string;
479
+ metrics: Record<string, number | string>;
480
+ collected_at: string;
481
+ }
456
482
  export interface PostAnalyticsPlatformEntry {
457
483
  platform: string;
458
484
  platform_post_id: string;
459
485
  metrics: Record<string, number | string>;
460
486
  thread_parts: number;
461
487
  collected_at: string;
488
+ /** Multi-slide stories only: per-slide metrics in publish order (the
489
+ * parent `metrics` is the sum across slides). */
490
+ story_slides?: PostAnalyticsStorySlide[];
462
491
  }
463
492
  export interface PostAnalytics {
464
493
  post_id: string;
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.24.0",
3
+ "version": "1.28.0",
4
+ "mcpName": "io.github.omnisocials/mcp-server",
4
5
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
5
6
  "type": "module",
6
7
  "main": "build/index.js",