@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.
@@ -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
- const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
40
- const comments = Number(m.comments ?? m.replies ?? 0);
41
- const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
42
- const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
43
- // Denominator for engagement rate: the broadest exposure count present.
44
- const denom = Number(m.impressions ?? m.views ?? m.reach ?? 0);
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
- const renderRate = (engagements, denom) => denom > 0 ? `| **Engagement Rate** | ${((engagements / denom) * 100).toFixed(2)}% |\n` : "";
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
- // 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;
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, stats] of sorted) {
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
- let md = `## Best times to post ${capitalize(d.platform)} (${d.timezone})\n\n`;
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 ${capitalize(d.platform)} 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`;
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} ${capitalize(d.platform)} posts from the last ${d.window_days} days (metric: ${d.metric}, times in ${d.timezone})._\n`;
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 : result.data?.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",
@@ -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, and LinkedIn company-page comments/mentions), newest activity first. Cursor-paginated: pass the returned cursor to get the next page.", {
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 LinkedIn. You can only reply to conversations that already exist. Direct-message replies must be within the platform's 24-hour messaging window. Each workspace can send up to 1,000 replies per day.", {
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 }) => {
@@ -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 : result.data?.media || [];
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
- const sz = r.file.size_known ? formatBytes(r.file.size_bytes) : "unknown size";
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 : result.data?.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
  {