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