@omnisocials/mcp-server 1.19.0 → 1.20.1
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 +5 -0
- package/build/client.d.ts +94 -100
- package/build/client.js +21 -4
- package/build/index.js +1 -1
- package/build/metrics.d.ts +24 -0
- package/build/metrics.js +111 -0
- package/build/tools/accounts.js +2 -2
- package/build/tools/analytics.js +49 -27
- package/build/tools/hashtag-sets.js +11 -1
- package/build/tools/inbox.js +4 -3
- package/build/tools/media.js +19 -3
- package/build/tools/posts.js +196 -206
- package/build/tools/webhooks.js +21 -1
- package/build/types.d.ts +295 -31
- package/package.json +1 -1
package/build/tools/analytics.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatNumber, capitalize, metricRows, sortMetricLabels } from "../client.js";
|
|
3
|
+
import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
|
|
3
4
|
export function registerAnalyticsTools(server, getClient) {
|
|
4
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
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."),
|
|
@@ -16,7 +17,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
16
17
|
// (thread posts publish as a chain), so we just total across platforms here.
|
|
17
18
|
// The legacy flat shape (d.impressions / d.platform_stats) doesn't exist on
|
|
18
19
|
// this endpoint and reading those keys returned "—" for every field.
|
|
19
|
-
const platforms = d?.platforms
|
|
20
|
+
const platforms = d?.platforms ?? {};
|
|
20
21
|
const platformEntries = Object.entries(platforms);
|
|
21
22
|
if (platformEntries.length === 0) {
|
|
22
23
|
return {
|
|
@@ -36,12 +37,14 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
36
37
|
for (const [platform, entry] of platformEntries) {
|
|
37
38
|
const m = entry?.metrics || {};
|
|
38
39
|
const rows = metricRows(m);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
40
|
+
// Engagement/impressions come from the shared normalizer (vendored in
|
|
41
|
+
// ../metrics.js) so the rate shown here matches the dashboard,
|
|
42
|
+
// /analytics/overview, and recent-platform. The previous inline
|
|
43
|
+
// likes+comments+shares sum silently dropped LinkedIn clicks and X
|
|
44
|
+
// quotes/bookmarks, so the same post showed a different engagement
|
|
45
|
+
// rate depending on the surface.
|
|
46
|
+
const engagements = getEngagement(platform, m);
|
|
47
|
+
const denom = getImpressions(platform, m);
|
|
45
48
|
totalEngagements += engagements;
|
|
46
49
|
totalDenom += denom;
|
|
47
50
|
for (const [label, n] of rows) {
|
|
@@ -60,7 +63,11 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
60
63
|
if (!totalsByLabel.has("Engagements"))
|
|
61
64
|
totalOrder.push("Engagements");
|
|
62
65
|
totalsByLabel.set("Engagements", totalEngagements);
|
|
63
|
-
|
|
66
|
+
// Suppressed below the shared impression floor (tiny denominators make
|
|
67
|
+
// noisy percentages), matching every other surface.
|
|
68
|
+
const renderRate = (engagements, denom) => denom >= MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE
|
|
69
|
+
? `| **Engagement Rate** | ${getEngagementRate(engagements, denom).toFixed(2)}% |\n`
|
|
70
|
+
: "";
|
|
64
71
|
let md = perPlatform.length === 1
|
|
65
72
|
? `## Post Analytics — ${capitalize(perPlatform[0].platform)}\n`
|
|
66
73
|
: `## Post Analytics\n\n`;
|
|
@@ -103,27 +110,26 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
103
110
|
// API returns { data: [{ post_id, platforms: { <platform>: { metrics } } }] }.
|
|
104
111
|
// Per-platform metrics are already thread-summed server-side; collapse each
|
|
105
112
|
// post's per-platform metrics into totals, mirroring get_post_analytics.
|
|
106
|
-
const entries = result.data
|
|
113
|
+
const entries = result.data ?? [];
|
|
107
114
|
const rows = entries.map((entry) => {
|
|
108
|
-
const platforms = entry?.platforms
|
|
115
|
+
const platforms = entry?.platforms ?? {};
|
|
109
116
|
const platformEntries = Object.entries(platforms);
|
|
110
117
|
let impressions = 0;
|
|
111
118
|
let engagements = 0;
|
|
112
119
|
let likes = 0;
|
|
113
120
|
let comments = 0;
|
|
114
121
|
let shares = 0;
|
|
115
|
-
for (const [, p] of platformEntries) {
|
|
122
|
+
for (const [name, p] of platformEntries) {
|
|
116
123
|
const m = p?.metrics || {};
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
likes +=
|
|
124
|
-
comments +=
|
|
125
|
-
shares +=
|
|
126
|
-
engagements += m.engagement != null ? Number(m.engagement) : l + c + s;
|
|
124
|
+
// Shared normalizer keeps these totals identical to get_post_analytics,
|
|
125
|
+
// the dashboard, and /analytics/overview (LinkedIn clicks and X
|
|
126
|
+
// quotes/bookmarks count toward engagement; the old inline sum dropped
|
|
127
|
+
// them). Likes/comments/shares stay as display columns only.
|
|
128
|
+
impressions += getImpressions(name, m);
|
|
129
|
+
engagements += getEngagement(name, m);
|
|
130
|
+
likes += Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
131
|
+
comments += Number(m.comments ?? m.replies ?? 0);
|
|
132
|
+
shares += Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
127
133
|
}
|
|
128
134
|
return {
|
|
129
135
|
post_id: entry.post_id,
|
|
@@ -161,6 +167,11 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
161
167
|
};
|
|
162
168
|
}
|
|
163
169
|
const d = result.data;
|
|
170
|
+
if (!d) {
|
|
171
|
+
return {
|
|
172
|
+
content: [{ type: "text", text: "No analytics overview data returned." }],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
164
175
|
const resolvedStart = result.start_date;
|
|
165
176
|
const resolvedEnd = result.end_date;
|
|
166
177
|
const currentDate = result.current_date;
|
|
@@ -183,8 +194,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
183
194
|
md += `|----------|-------|-------------|-------------|----------|\n`;
|
|
184
195
|
// Sort by impressions descending
|
|
185
196
|
const sorted = Object.entries(d.platform_breakdown).sort((a, b) => (b[1].total_impressions || 0) - (a[1].total_impressions || 0));
|
|
186
|
-
for (const [platform,
|
|
187
|
-
const s = stats;
|
|
197
|
+
for (const [platform, s] of sorted) {
|
|
188
198
|
const rate = s.engagement_rate !== undefined
|
|
189
199
|
? `${s.engagement_rate.toFixed(2)}%`
|
|
190
200
|
: "—";
|
|
@@ -195,7 +205,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
195
205
|
content: [{ type: "text", text: md }],
|
|
196
206
|
};
|
|
197
207
|
});
|
|
198
|
-
server.tool("get_account_analytics", "Get account-level analytics (followers, subscribers, etc.) for connected social accounts.", {
|
|
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.", {
|
|
199
209
|
platform: z.string().optional().describe("Filter by platform (e.g., instagram, youtube)"),
|
|
200
210
|
date: z.string().optional().describe("Date to get analytics for (YYYY-MM-DD, defaults to today)"),
|
|
201
211
|
}, async (params) => {
|
|
@@ -221,6 +231,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
221
231
|
const fb = b.metrics?.followers ?? 0;
|
|
222
232
|
return fb - fa;
|
|
223
233
|
});
|
|
234
|
+
const notes = [];
|
|
224
235
|
for (const a of sorted) {
|
|
225
236
|
const m = a.metrics || {};
|
|
226
237
|
const followers = m.followers !== undefined
|
|
@@ -245,6 +256,14 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
245
256
|
details.push(`${formatNumber(m.subscribers)} subscribers`);
|
|
246
257
|
}
|
|
247
258
|
md += `| ${capitalize(a.platform)} | ${followers} | ${details.join(", ") || "—"} |\n`;
|
|
259
|
+
// Scope notes (e.g. "LinkedIn impressions are lifetime totals") must
|
|
260
|
+
// reach the user, or lifetime counters get read as windowed numbers.
|
|
261
|
+
if (typeof m.note === "string" && m.note) {
|
|
262
|
+
notes.push(`- **${capitalize(a.platform)}:** ${m.note}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (notes.length) {
|
|
266
|
+
md += `\n${notes.join("\n")}\n`;
|
|
248
267
|
}
|
|
249
268
|
return {
|
|
250
269
|
content: [{ type: "text", text: md }],
|
|
@@ -261,7 +280,10 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
261
280
|
};
|
|
262
281
|
}
|
|
263
282
|
const d = result;
|
|
264
|
-
|
|
283
|
+
// On success the service always returns platform/timezone; params is the
|
|
284
|
+
// typed fallback so the label never renders "undefined".
|
|
285
|
+
const platformLabel = capitalize(d.platform || params.platform);
|
|
286
|
+
let md = `## Best times to post — ${platformLabel} (${d.timezone})\n\n`;
|
|
265
287
|
md += `**Top slots:**\n`;
|
|
266
288
|
(d.recommendations || []).forEach((r, i) => {
|
|
267
289
|
const extras = [];
|
|
@@ -294,10 +316,10 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
294
316
|
md += `\n### Best hours per day (score 0-100)\n\n| Day | Best hours |\n|-----|------------|\n${dayRows.join("\n")}\n`;
|
|
295
317
|
}
|
|
296
318
|
if (d.basis === "defaults") {
|
|
297
|
-
md += `\n_Not enough posting history on ${
|
|
319
|
+
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`;
|
|
298
320
|
}
|
|
299
321
|
else {
|
|
300
|
-
md += `\n_Based on ${d.sample_size} ${
|
|
322
|
+
md += `\n_Based on ${d.sample_size} ${platformLabel} posts from the last ${d.window_days} days (metric: ${d.metric}, times in ${d.timezone})._\n`;
|
|
301
323
|
}
|
|
302
324
|
return {
|
|
303
325
|
content: [{ type: "text", text: md }],
|
|
@@ -7,7 +7,7 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
7
7
|
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
|
-
const sets = Array.isArray(result.data) ? result.data :
|
|
10
|
+
const sets = Array.isArray(result.data) ? result.data : [];
|
|
11
11
|
if (!sets.length) {
|
|
12
12
|
return {
|
|
13
13
|
content: [{ type: "text", text: "No hashtag sets yet. Create one with create_hashtag_set." }],
|
|
@@ -36,6 +36,11 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
const s = result.data;
|
|
39
|
+
if (!s) {
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: "text", text: "Hashtag set created, but the API returned no details. List it with list_hashtag_sets." }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
39
44
|
return {
|
|
40
45
|
content: [{
|
|
41
46
|
type: "text",
|
|
@@ -55,6 +60,11 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
55
60
|
};
|
|
56
61
|
}
|
|
57
62
|
const s = result.data;
|
|
63
|
+
if (!s) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: "Hashtag set updated, but the API returned no details. List it with list_hashtag_sets." }],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
58
68
|
return {
|
|
59
69
|
content: [{
|
|
60
70
|
type: "text",
|
package/build/tools/inbox.js
CHANGED
|
@@ -4,11 +4,12 @@ const PLATFORM_EMOJI = {
|
|
|
4
4
|
instagram: "📸",
|
|
5
5
|
facebook: "📘",
|
|
6
6
|
linkedin: "💼",
|
|
7
|
+
x: "🐦",
|
|
7
8
|
};
|
|
8
9
|
export function registerInboxTools(server, getClient) {
|
|
9
|
-
server.tool("list_inbox_conversations", "List social inbox conversations (Instagram/Facebook DMs, comments, mentions,
|
|
10
|
+
server.tool("list_inbox_conversations", "List social inbox conversations (Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, and X DMs where the workspace has opted into X DMs), newest activity first. Cursor-paginated: pass the returned cursor to get the next page.", {
|
|
10
11
|
platform: z
|
|
11
|
-
.enum(["instagram", "facebook", "linkedin"])
|
|
12
|
+
.enum(["instagram", "facebook", "linkedin", "x"])
|
|
12
13
|
.optional()
|
|
13
14
|
.describe("Filter to one platform"),
|
|
14
15
|
type: z
|
|
@@ -136,7 +137,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
136
137
|
],
|
|
137
138
|
};
|
|
138
139
|
});
|
|
139
|
-
server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, or
|
|
140
|
+
server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, or X. You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost) — a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day.", {
|
|
140
141
|
conversation_id: z.string().describe("The conversation ID to reply to"),
|
|
141
142
|
text: z.string().describe("The reply text (max 2000 characters)"),
|
|
142
143
|
}, async ({ conversation_id, text }) => {
|
package/build/tools/media.js
CHANGED
|
@@ -13,7 +13,7 @@ export function registerMediaTools(server, getClient) {
|
|
|
13
13
|
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
14
14
|
};
|
|
15
15
|
}
|
|
16
|
-
const items = Array.isArray(result.data) ? result.data :
|
|
16
|
+
const items = Array.isArray(result.data) ? result.data : [];
|
|
17
17
|
if (!items.length) {
|
|
18
18
|
const hint = params?.search
|
|
19
19
|
? `No media found matching "${params.search}". It hasn't been uploaded yet — upload it (and pass a \`name\`) so it's findable next time.`
|
|
@@ -122,6 +122,11 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
122
122
|
return { content: [{ type: "text", text: md }] };
|
|
123
123
|
}
|
|
124
124
|
const m = result.data;
|
|
125
|
+
if (!m) {
|
|
126
|
+
return {
|
|
127
|
+
content: [{ type: "text", text: "Upload succeeded, but the API returned no media details. Find the file with list_media." }],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
125
130
|
const isProcessing = m.status === "processing";
|
|
126
131
|
let md = isProcessing ? `## Media Processing\n\n` : `## Media Uploaded\n\n`;
|
|
127
132
|
md += `| Field | Value |\n`;
|
|
@@ -170,7 +175,8 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
170
175
|
const connected = r.connected_platforms || [];
|
|
171
176
|
let md = `## Compatibility Check\n\n`;
|
|
172
177
|
if (r.file) {
|
|
173
|
-
|
|
178
|
+
// size_known implies size_bytes is a finite number (media.js check route).
|
|
179
|
+
const sz = r.file.size_known ? formatBytes(r.file.size_bytes ?? 0) : "unknown size";
|
|
174
180
|
md += `File: ${sz}${r.file.mime ? ` · ${r.file.mime}` : ""}\n`;
|
|
175
181
|
}
|
|
176
182
|
md += `Connected platforms: ${connected.length ? connected.join(", ") : "none"}\n\n`;
|
|
@@ -204,6 +210,11 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
204
210
|
};
|
|
205
211
|
}
|
|
206
212
|
const m = result.data;
|
|
213
|
+
if (!m) {
|
|
214
|
+
return {
|
|
215
|
+
content: [{ type: "text", text: "Media updated, but the API returned no details. Verify with list_media." }],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
207
218
|
return {
|
|
208
219
|
content: [
|
|
209
220
|
{
|
|
@@ -218,7 +229,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
218
229
|
if (result.error) {
|
|
219
230
|
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
220
231
|
}
|
|
221
|
-
const items = Array.isArray(result.data) ? result.data :
|
|
232
|
+
const items = Array.isArray(result.data) ? result.data : [];
|
|
222
233
|
if (!items.length) {
|
|
223
234
|
return { content: [{ type: "text", text: "No folders yet. Create one with create_folder." }] };
|
|
224
235
|
}
|
|
@@ -238,6 +249,11 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
238
249
|
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
239
250
|
}
|
|
240
251
|
const f = result.data;
|
|
252
|
+
if (!f) {
|
|
253
|
+
return {
|
|
254
|
+
content: [{ type: "text", text: "Folder created, but the API returned no details. Verify with list_folders." }],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
241
257
|
return {
|
|
242
258
|
content: [
|
|
243
259
|
{
|