@omnisocials/mcp-server 1.10.0 → 1.12.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 +9 -0
- package/build/client.js +72 -0
- package/build/index.js +1 -1
- package/build/tools/analytics.js +70 -41
- package/build/tools/posts.js +94 -16
- package/package.json +1 -1
package/build/client.d.ts
CHANGED
|
@@ -9,6 +9,14 @@ 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
|
+
export declare function sortMetricLabels(labels: string[]): string[];
|
|
13
|
+
/**
|
|
14
|
+
* Flatten a raw per-platform metrics object into ordered [label, value] rows,
|
|
15
|
+
* keeping every numeric counter the platform reported. Strings (note, title,
|
|
16
|
+
* cover_image_url, …) are skipped; `plays` is a legacy always-0 Instagram
|
|
17
|
+
* back-compat key so a zero there is noise, not data.
|
|
18
|
+
*/
|
|
19
|
+
export declare function metricRows(m: Record<string, unknown> | null | undefined): Array<[string, number]>;
|
|
12
20
|
export interface XThreadPartInput {
|
|
13
21
|
/** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
|
|
14
22
|
text: string;
|
|
@@ -179,6 +187,7 @@ export declare class OmniSocialsClient {
|
|
|
179
187
|
publishPost(id: string): Promise<ApiResponse<unknown>>;
|
|
180
188
|
searchLocations(query: string): Promise<ApiResponse<unknown>>;
|
|
181
189
|
validateLocation(id: string): Promise<ApiResponse<unknown>>;
|
|
190
|
+
searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<ApiResponse<unknown>>;
|
|
182
191
|
listMedia(params?: {
|
|
183
192
|
limit?: string;
|
|
184
193
|
offset?: string;
|
package/build/client.js
CHANGED
|
@@ -89,6 +89,69 @@ export function truncate(value, len) {
|
|
|
89
89
|
export function capitalize(str) {
|
|
90
90
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
91
91
|
}
|
|
92
|
+
// ─── Metric Rendering ───────────────────────────────────────────────────────
|
|
93
|
+
// Canonical render order + labels for per-platform metric objects. Any numeric
|
|
94
|
+
// key the API returns that isn't listed here still renders (prettified from
|
|
95
|
+
// snake_case) so no metric is ever silently dropped — reach/saves/views went
|
|
96
|
+
// missing for months because the old renderers used a fixed column set.
|
|
97
|
+
const METRIC_LABELS = [
|
|
98
|
+
["impressions", "Impressions"],
|
|
99
|
+
["reach", "Reach"],
|
|
100
|
+
["views", "Views"],
|
|
101
|
+
["likes", "Likes"],
|
|
102
|
+
["comments", "Comments"],
|
|
103
|
+
["shares", "Shares"],
|
|
104
|
+
["saves", "Saves"],
|
|
105
|
+
["clicks", "Clicks"],
|
|
106
|
+
["reposts", "Reposts"],
|
|
107
|
+
["quotes", "Quotes"],
|
|
108
|
+
["bookmarks", "Bookmarks"],
|
|
109
|
+
["replies", "Replies"],
|
|
110
|
+
["reactions", "Reactions"],
|
|
111
|
+
["total_reactions", "Total reactions"],
|
|
112
|
+
["favorites", "Favorites"],
|
|
113
|
+
["plays", "Plays"],
|
|
114
|
+
["engagement", "Engagements"],
|
|
115
|
+
];
|
|
116
|
+
// Context keys that ride along in stored metrics — not counters.
|
|
117
|
+
const NON_COUNTER_KEYS = new Set(["create_time"]);
|
|
118
|
+
/** Canonical position of a metric label — for sorting totals rows collected
|
|
119
|
+
* across platforms back into METRIC_LABELS order (unknown labels sink). */
|
|
120
|
+
const LABEL_ORDER = new Map(METRIC_LABELS.map(([, label], i) => [label, i]));
|
|
121
|
+
export function sortMetricLabels(labels) {
|
|
122
|
+
return [...labels].sort((a, b) => (LABEL_ORDER.get(a) ?? 999) - (LABEL_ORDER.get(b) ?? 999));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Flatten a raw per-platform metrics object into ordered [label, value] rows,
|
|
126
|
+
* keeping every numeric counter the platform reported. Strings (note, title,
|
|
127
|
+
* cover_image_url, …) are skipped; `plays` is a legacy always-0 Instagram
|
|
128
|
+
* back-compat key so a zero there is noise, not data.
|
|
129
|
+
*/
|
|
130
|
+
export function metricRows(m) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
for (const [key, label] of METRIC_LABELS) {
|
|
134
|
+
const v = m?.[key];
|
|
135
|
+
if (v === undefined || v === null)
|
|
136
|
+
continue;
|
|
137
|
+
const n = Number(v);
|
|
138
|
+
if (!Number.isFinite(n))
|
|
139
|
+
continue;
|
|
140
|
+
seen.add(key);
|
|
141
|
+
if (key === "plays" && n === 0)
|
|
142
|
+
continue;
|
|
143
|
+
rows.push([label, n]);
|
|
144
|
+
}
|
|
145
|
+
for (const [key, v] of Object.entries(m || {})) {
|
|
146
|
+
if (seen.has(key) || NON_COUNTER_KEYS.has(key))
|
|
147
|
+
continue;
|
|
148
|
+
const n = Number(v);
|
|
149
|
+
if (!Number.isFinite(n))
|
|
150
|
+
continue;
|
|
151
|
+
rows.push([capitalize(key.replace(/_/g, " ")), n]);
|
|
152
|
+
}
|
|
153
|
+
return rows;
|
|
154
|
+
}
|
|
92
155
|
export class OmniSocialsClient {
|
|
93
156
|
baseUrl;
|
|
94
157
|
apiKey;
|
|
@@ -163,6 +226,15 @@ export class OmniSocialsClient {
|
|
|
163
226
|
async validateLocation(id) {
|
|
164
227
|
return this.request("GET", "/locations/validate", undefined, { id });
|
|
165
228
|
}
|
|
229
|
+
// Audio (Instagram Reel music)
|
|
230
|
+
async searchInstagramAudio(query, type) {
|
|
231
|
+
const params = {};
|
|
232
|
+
if (query)
|
|
233
|
+
params.q = query;
|
|
234
|
+
if (type)
|
|
235
|
+
params.type = type;
|
|
236
|
+
return this.request("GET", "/audio/search", undefined, params);
|
|
237
|
+
}
|
|
166
238
|
// Media
|
|
167
239
|
async listMedia(params) {
|
|
168
240
|
return this.request("GET", "/media", undefined, params);
|
package/build/index.js
CHANGED
|
@@ -27,7 +27,7 @@ const sessionState = { activeIndex: 0 };
|
|
|
27
27
|
const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
|
|
28
28
|
const server = new McpServer({
|
|
29
29
|
name: "OmniSocials",
|
|
30
|
-
version: "1.
|
|
30
|
+
version: "1.12.0",
|
|
31
31
|
});
|
|
32
32
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
33
33
|
registerPostTools(server, getActiveClient);
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatNumber, capitalize } from "../client.js";
|
|
2
|
+
import { formatNumber, capitalize, metricRows, sortMetricLabels } from "../client.js";
|
|
3
3
|
export function registerAnalyticsTools(server, getClient) {
|
|
4
|
-
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post
|
|
4
|
+
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
5
|
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."),
|
|
6
6
|
}, async ({ post_id }) => {
|
|
7
7
|
const result = await getClient().getPostAnalytics(post_id);
|
|
@@ -26,41 +26,62 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
26
26
|
}],
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// Every numeric metric the platform reported is rendered via metricRows —
|
|
30
|
+
// a fixed column set here is how reach/saves/views silently vanished.
|
|
30
31
|
const perPlatform = [];
|
|
32
|
+
const totalOrder = [];
|
|
33
|
+
const totalsByLabel = new Map();
|
|
34
|
+
let totalEngagements = 0;
|
|
35
|
+
let totalDenom = 0;
|
|
31
36
|
for (const [platform, entry] of platformEntries) {
|
|
32
37
|
const m = entry?.metrics || {};
|
|
33
|
-
const
|
|
34
|
-
? Number(m.reach ?? m.impressions ?? 0)
|
|
35
|
-
: Number(m.views ?? m.impressions ?? 0);
|
|
38
|
+
const rows = metricRows(m);
|
|
36
39
|
const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
37
40
|
const comments = Number(m.comments ?? m.replies ?? 0);
|
|
38
41
|
const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
39
42
|
const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
// Denominator for engagement rate: the broadest exposure count present.
|
|
44
|
+
const denom = Number(m.impressions ?? m.views ?? m.reach ?? 0);
|
|
45
|
+
totalEngagements += engagements;
|
|
46
|
+
totalDenom += denom;
|
|
47
|
+
for (const [label, n] of rows) {
|
|
48
|
+
if (!totalsByLabel.has(label))
|
|
49
|
+
totalOrder.push(label);
|
|
50
|
+
totalsByLabel.set(label, (totalsByLabel.get(label) || 0) + n);
|
|
51
|
+
}
|
|
52
|
+
perPlatform.push({
|
|
53
|
+
platform,
|
|
54
|
+
rows,
|
|
55
|
+
engagements,
|
|
56
|
+
denom,
|
|
57
|
+
note: typeof m.note === "string" ? m.note : undefined,
|
|
58
|
+
});
|
|
46
59
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
md
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
60
|
+
if (!totalsByLabel.has("Engagements"))
|
|
61
|
+
totalOrder.push("Engagements");
|
|
62
|
+
totalsByLabel.set("Engagements", totalEngagements);
|
|
63
|
+
const renderRate = (engagements, denom) => denom > 0 ? `| **Engagement Rate** | ${((engagements / denom) * 100).toFixed(2)}% |\n` : "";
|
|
64
|
+
let md = perPlatform.length === 1
|
|
65
|
+
? `## Post Analytics — ${capitalize(perPlatform[0].platform)}\n`
|
|
66
|
+
: `## Post Analytics\n\n`;
|
|
67
|
+
if (perPlatform.length > 1) {
|
|
68
|
+
md += `| Metric | Value |\n|--------|-------|\n`;
|
|
69
|
+
for (const label of sortMetricLabels(totalOrder)) {
|
|
70
|
+
md += `| **${label}** | ${formatNumber(totalsByLabel.get(label) || 0)} |\n`;
|
|
71
|
+
}
|
|
72
|
+
md += renderRate(totalEngagements, totalDenom);
|
|
73
|
+
md += `\n### Per-Platform Breakdown\n`;
|
|
58
74
|
}
|
|
59
|
-
md += `\n### Per-Platform Breakdown\n\n`;
|
|
60
|
-
md += `| Platform | Impressions | Engagements | Likes | Comments | Shares |\n`;
|
|
61
|
-
md += `|----------|-------------|-------------|-------|----------|--------|\n`;
|
|
62
75
|
for (const p of perPlatform) {
|
|
63
|
-
|
|
76
|
+
if (perPlatform.length > 1)
|
|
77
|
+
md += `\n#### ${capitalize(p.platform)}\n`;
|
|
78
|
+
md += `\n| Metric | Value |\n|--------|-------|\n`;
|
|
79
|
+
for (const [label, n] of p.rows) {
|
|
80
|
+
md += `| **${label}** | ${formatNumber(n)} |\n`;
|
|
81
|
+
}
|
|
82
|
+
md += renderRate(p.engagements, p.denom);
|
|
83
|
+
if (p.note)
|
|
84
|
+
md += `\n_${p.note}_\n`;
|
|
64
85
|
}
|
|
65
86
|
return {
|
|
66
87
|
content: [{ type: "text", text: md }],
|
|
@@ -88,17 +109,21 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
88
109
|
const platformEntries = Object.entries(platforms);
|
|
89
110
|
let impressions = 0;
|
|
90
111
|
let engagements = 0;
|
|
91
|
-
|
|
112
|
+
let likes = 0;
|
|
113
|
+
let comments = 0;
|
|
114
|
+
let shares = 0;
|
|
115
|
+
for (const [, p] of platformEntries) {
|
|
92
116
|
const m = p?.metrics || {};
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
const
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
117
|
+
// Broadest exposure count present, regardless of the platform's name
|
|
118
|
+
// for it (LinkedIn: impressions, TikTok/X/YouTube: views, IG: reach).
|
|
119
|
+
impressions += Number(m.impressions ?? m.views ?? m.reach ?? 0);
|
|
120
|
+
const l = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
121
|
+
const c = Number(m.comments ?? m.replies ?? 0);
|
|
122
|
+
const s = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
123
|
+
likes += l;
|
|
124
|
+
comments += c;
|
|
125
|
+
shares += s;
|
|
126
|
+
engagements += m.engagement != null ? Number(m.engagement) : l + c + s;
|
|
102
127
|
}
|
|
103
128
|
return {
|
|
104
129
|
post_id: entry.post_id,
|
|
@@ -106,16 +131,20 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
106
131
|
platformNames: platformEntries.map(([name]) => capitalize(name)).join(", "),
|
|
107
132
|
impressions,
|
|
108
133
|
engagements,
|
|
134
|
+
likes,
|
|
135
|
+
comments,
|
|
136
|
+
shares,
|
|
109
137
|
};
|
|
110
138
|
});
|
|
111
139
|
const withData = rows.filter((r) => r.platformCount > 0).length;
|
|
112
140
|
let md = `## Posts Analytics (${rows.length} posts, ${withData} with collected stats)\n\n`;
|
|
113
|
-
md += `| Post ID | Platforms | Impressions | Engagements |\n`;
|
|
114
|
-
md +=
|
|
141
|
+
md += `| Post ID | Platforms | Impressions | Engagements | Likes | Comments | Shares |\n`;
|
|
142
|
+
md += `|---------|-----------|-------------|-------------|-------|----------|--------|\n`;
|
|
115
143
|
for (const r of rows) {
|
|
116
|
-
|
|
144
|
+
const c = (n) => (r.platformCount ? formatNumber(n) : "—");
|
|
145
|
+
md += `| ${r.post_id} | ${r.platformNames || "—"} | ${c(r.impressions)} | ${c(r.engagements)} | ${c(r.likes)} | ${c(r.comments)} | ${c(r.shares)} |\n`;
|
|
117
146
|
}
|
|
118
|
-
md += `\nPosts showing "—" have no analytics collected yet. Stats are fetched periodically after publishing; check back in a few hours.\n`;
|
|
147
|
+
md += `\nPosts showing "—" have no analytics collected yet. Stats are fetched periodically after publishing; check back in a few hours. Use get_post_analytics on a single post for the full per-platform metric set (reach, views, saves, clicks, …).\n`;
|
|
119
148
|
return {
|
|
120
149
|
content: [{ type: "text", text: md }],
|
|
121
150
|
};
|
package/build/tools/posts.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatDateTime, truncate, capitalize, formatNumber } from "../client.js";
|
|
2
|
+
import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
|
|
3
3
|
// Reusable per-platform option objects for the "first comment" feature — text
|
|
4
4
|
// auto-posted as a comment on the post right after it publishes (hashtags out
|
|
5
5
|
// of the caption, "link in first comment", etc.). Only platforms with a
|
|
@@ -140,7 +140,7 @@ export function registerPostTools(server, getClient) {
|
|
|
140
140
|
content: [{ type: "text", text: md }],
|
|
141
141
|
};
|
|
142
142
|
});
|
|
143
|
-
server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs), first comment, Instagram collaborators/user tags/location, 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`. 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.", {
|
|
143
|
+
server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs), first comment, Instagram collaborators/user tags/location/Trial Reel state, 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`. 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.", {
|
|
144
144
|
id: z.string().describe("The post ID"),
|
|
145
145
|
}, async ({ id }) => {
|
|
146
146
|
const result = await getClient().getPost(id);
|
|
@@ -290,6 +290,16 @@ export function registerPostTools(server, getClient) {
|
|
|
290
290
|
if (firstCommentRows.length) {
|
|
291
291
|
md += `\n\n### First Comments\n\n${firstCommentRows.join("\n")}\n`;
|
|
292
292
|
}
|
|
293
|
+
// Trial Reel state: nested under the instagram options object by the
|
|
294
|
+
// API. Only rendered when enabled, so batch-scheduled Trial Reels can be
|
|
295
|
+
// verified without opening the dashboard.
|
|
296
|
+
const igOpts = p.instagram;
|
|
297
|
+
if (igOpts && typeof igOpts === "object" && igOpts.is_trial_reel === true) {
|
|
298
|
+
const strategy = igOpts.trial_graduation_strategy === "SS_PERFORMANCE"
|
|
299
|
+
? "automatic (Instagram shares it with followers if it performs well)"
|
|
300
|
+
: "manual (graduate it yourself in the Instagram app)";
|
|
301
|
+
md += `\n\n**Instagram Trial Reel:** enabled — shown to non-followers first; graduation: ${strategy}\n`;
|
|
302
|
+
}
|
|
293
303
|
return {
|
|
294
304
|
content: [{ type: "text", text: md }],
|
|
295
305
|
};
|
|
@@ -385,7 +395,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
385
395
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
|
|
386
396
|
thumb_offset: z.number().optional(),
|
|
387
397
|
cover_url: z.string().optional(),
|
|
398
|
+
audio_id: z.string().optional().describe("Reels only. Licensed music track to attach — a numeric audio ID from search_instagram_audio. Requires a Facebook account connected to the workspace whose Page links this Instagram account."),
|
|
399
|
+
audio_volume: z.number().int().min(0).max(100).optional().describe("Volume of the attached music track, 0-100 (default 100). Only used with audio_id."),
|
|
400
|
+
video_volume: z.number().int().min(0).max(100).optional().describe("Volume of the video's own audio, 0-100 (default 100). Set 0 for a music-only Reel. Only used with audio_id."),
|
|
388
401
|
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
|
|
402
|
+
is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
|
|
403
|
+
trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
|
|
389
404
|
}).optional().describe("Instagram options"),
|
|
390
405
|
facebook: FACEBOOK_OPTIONS,
|
|
391
406
|
linkedin: LINKEDIN_OPTIONS,
|
|
@@ -393,10 +408,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
393
408
|
tiktok: z.object({
|
|
394
409
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
395
410
|
disable_comment: z.boolean().optional(),
|
|
396
|
-
disable_duet: z.boolean().optional(),
|
|
397
|
-
disable_stitch: z.boolean().optional(),
|
|
398
|
-
|
|
399
|
-
|
|
411
|
+
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
412
|
+
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
413
|
+
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
414
|
+
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
415
|
+
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
416
|
+
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
400
417
|
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
401
418
|
}).optional().describe("TikTok options"),
|
|
402
419
|
x: z.object({
|
|
@@ -544,13 +561,26 @@ Do NOT call without required media — it will fail.`, {
|
|
|
544
561
|
first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
|
|
545
562
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
546
563
|
instagram: z.object({
|
|
564
|
+
audio_id: z.string().optional().describe("Reels only. Licensed music track to attach — a numeric audio ID from search_instagram_audio. Requires a Facebook account connected to the workspace whose Page links this Instagram account."),
|
|
565
|
+
audio_volume: z.number().int().min(0).max(100).optional().describe("Volume of the attached music track, 0-100 (default 100). Only used with audio_id."),
|
|
566
|
+
video_volume: z.number().int().min(0).max(100).optional().describe("Volume of the video's own audio, 0-100 (default 100). Set 0 for a music-only Reel. Only used with audio_id."),
|
|
547
567
|
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
|
|
568
|
+
is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
|
|
569
|
+
trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
|
|
548
570
|
}).optional().describe("Instagram options"),
|
|
549
571
|
facebook: FACEBOOK_OPTIONS,
|
|
550
572
|
linkedin: LINKEDIN_OPTIONS,
|
|
551
573
|
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
552
574
|
tiktok: z.object({
|
|
553
575
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
576
|
+
disable_comment: z.boolean().optional(),
|
|
577
|
+
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
578
|
+
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
579
|
+
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
580
|
+
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
581
|
+
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
582
|
+
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
583
|
+
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
554
584
|
}).optional().describe("TikTok options"),
|
|
555
585
|
x: z.object({
|
|
556
586
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
@@ -675,18 +705,25 @@ Do NOT call without required media — it will fail.`, {
|
|
|
675
705
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
|
|
676
706
|
thumb_offset: z.number().optional(),
|
|
677
707
|
cover_url: z.string().optional(),
|
|
708
|
+
audio_id: z.string().optional().describe("Reels only. Licensed music track to attach — a numeric audio ID from search_instagram_audio. Requires a Facebook account connected to the workspace whose Page links this Instagram account."),
|
|
709
|
+
audio_volume: z.number().int().min(0).max(100).optional().describe("Volume of the attached music track, 0-100 (default 100). Only used with audio_id."),
|
|
710
|
+
video_volume: z.number().int().min(0).max(100).optional().describe("Volume of the video's own audio, 0-100 (default 100). Set 0 for a music-only Reel. Only used with audio_id."),
|
|
678
711
|
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Pass an empty string to clear a previously set first comment."),
|
|
679
|
-
|
|
712
|
+
is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account. Ineligible accounts fail at publish time with a clear per-platform error. Reels only. Pass false to turn a Trial Reel back into a regular reel."),
|
|
713
|
+
trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
|
|
714
|
+
}).optional().describe("Instagram options. MERGED into the post's stored options: omitted keys keep their current value (updating one option cannot clear a Trial Reel flag or reel music configured elsewhere)."),
|
|
680
715
|
facebook: FACEBOOK_OPTIONS,
|
|
681
716
|
linkedin: LINKEDIN_OPTIONS,
|
|
682
717
|
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
683
718
|
tiktok: z.object({
|
|
684
719
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
685
720
|
disable_comment: z.boolean().optional(),
|
|
686
|
-
disable_duet: z.boolean().optional(),
|
|
687
|
-
disable_stitch: z.boolean().optional(),
|
|
688
|
-
|
|
689
|
-
|
|
721
|
+
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
722
|
+
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
723
|
+
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
724
|
+
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
725
|
+
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
726
|
+
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
690
727
|
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
691
728
|
}).optional().describe("TikTok options"),
|
|
692
729
|
x: z.object({
|
|
@@ -812,7 +849,43 @@ Notes:
|
|
|
812
849
|
});
|
|
813
850
|
return { content: [{ type: "text", text: md }] };
|
|
814
851
|
});
|
|
815
|
-
server.tool("
|
|
852
|
+
server.tool("search_instagram_audio", `Search Instagram's licensed music catalog for a track to attach to a REEL. Use this whenever the user wants to post a reel WITH music/a song/a sound (e.g. "post this reel with trending audio", "add that song to it").
|
|
853
|
+
|
|
854
|
+
Flow: call with a song/artist/keyword (or NO query for currently trending audio) → present the options → once the user picks, pass that result's \`audio_id\` as \`instagram.audio_id\` on create_post / create_and_publish_post / update_post. Optionally mix with \`instagram.audio_volume\` / \`instagram.video_volume\` (0-100; set video_volume 0 to fully replace the video's own sound). Instagram Reels only — feed posts, carousels, and Stories can't take music via the API (Meta limitation).
|
|
855
|
+
|
|
856
|
+
Notes: only tracks Meta licenses for third-party publishing appear, so the selection can differ from the Instagram app. Requires the workspace to have a Facebook account connected whose Page is linked to this Instagram account — if results say Facebook is needed, tell the user to connect it under Settings → Organisation → Workspaces.`, {
|
|
857
|
+
query: z
|
|
858
|
+
.string()
|
|
859
|
+
.optional()
|
|
860
|
+
.describe("Song title, artist, or keyword. OMIT for currently trending audio."),
|
|
861
|
+
type: z
|
|
862
|
+
.enum(["music", "original_sound"])
|
|
863
|
+
.optional()
|
|
864
|
+
.describe("Catalog to search: licensed music (default) or original sounds created by Instagram users."),
|
|
865
|
+
}, async ({ query, type }) => {
|
|
866
|
+
const result = await getClient().searchInstagramAudio(query, type);
|
|
867
|
+
const tracks = result?.data || [];
|
|
868
|
+
if (!tracks.length) {
|
|
869
|
+
const why = result?.error ||
|
|
870
|
+
(query
|
|
871
|
+
? `No tracks found for "${query}". Try another title or artist — only music licensed for third-party publishing appears here.`
|
|
872
|
+
: "No trending audio available right now.");
|
|
873
|
+
return { content: [{ type: "text", text: why }] };
|
|
874
|
+
}
|
|
875
|
+
const fmtDuration = (ms) => {
|
|
876
|
+
if (!ms)
|
|
877
|
+
return "—";
|
|
878
|
+
const s = Math.round(ms / 1000);
|
|
879
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
880
|
+
};
|
|
881
|
+
let md = `## ${query ? `Audio matching "${query}"` : "Trending audio"}\n\nAsk the user which track, then pass its ID as \`instagram.audio_id\` (reels only):\n\n`;
|
|
882
|
+
md += `| # | Title | Artist | Length | audio_id |\n|---|-------|--------|--------|----------|\n`;
|
|
883
|
+
tracks.forEach((t, i) => {
|
|
884
|
+
md += `| ${i + 1} | ${t.title || "—"} | ${t.artist || t.ig_username || "—"} | ${fmtDuration(t.duration_ms)} | \`${t.audio_id}\` |\n`;
|
|
885
|
+
});
|
|
886
|
+
return { content: [{ type: "text", text: md }] };
|
|
887
|
+
});
|
|
888
|
+
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Requires the analytics:read scope.", {
|
|
816
889
|
limit: z
|
|
817
890
|
.string()
|
|
818
891
|
.optional()
|
|
@@ -841,15 +914,20 @@ Notes:
|
|
|
841
914
|
};
|
|
842
915
|
}
|
|
843
916
|
let md = `## Recent platform posts (${posts.length} across ${connected.length} platform${connected.length === 1 ? "" : "s"})\n\n`;
|
|
844
|
-
md += `| # | Platform | Format | Content | Engagement |
|
|
845
|
-
md +=
|
|
917
|
+
md += `| # | Platform | Format | Content | Engagement | Metrics | Date |\n`;
|
|
918
|
+
md += `|---|----------|--------|---------|------------|---------|------|\n`;
|
|
846
919
|
posts.forEach((p, i) => {
|
|
847
920
|
const hasMetrics = p.metrics && Object.keys(p.metrics).length > 0;
|
|
848
921
|
const content = truncate(clean(p.text), 45) || "*(no caption)*";
|
|
849
922
|
const eng = hasMetrics ? formatNumber(p.engagement || 0) : "—";
|
|
850
|
-
|
|
923
|
+
// Every counter the platform reported, compact: "1.4K views · 12 saves".
|
|
924
|
+
// Engagement has its own column, so skip its row here.
|
|
925
|
+
const detail = metricRows(p.metrics)
|
|
926
|
+
.filter(([label]) => label !== "Engagements")
|
|
927
|
+
.map(([label, n]) => `${formatNumber(n)} ${label.toLowerCase()}`)
|
|
928
|
+
.join(" · ");
|
|
851
929
|
const date = p.timestamp ? formatDateTime(p.timestamp) : "—";
|
|
852
|
-
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${
|
|
930
|
+
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
|
|
853
931
|
});
|
|
854
932
|
if (result.note)
|
|
855
933
|
md += `\n${result.note}\n`;
|
package/package.json
CHANGED