@omnisocials/mcp-server 1.10.0 → 1.11.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 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;
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;
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.10.0",
30
+ version: "1.11.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);
@@ -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 (impressions, engagements, likes, etc.).", {
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
- const totals = { impressions: 0, engagements: 0, likes: 0, comments: 0, shares: 0 };
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 impressions = platform === "instagram"
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
- totals.impressions += impressions;
41
- totals.engagements += engagements;
42
- totals.likes += likes;
43
- totals.comments += comments;
44
- totals.shares += shares;
45
- perPlatform.push({ platform, impressions, engagements, likes, comments, shares });
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
- let md = `## Post Analytics\n\n`;
48
- md += `| Metric | Value |\n`;
49
- md += `|--------|-------|\n`;
50
- md += `| **Impressions** | ${formatNumber(totals.impressions)} |\n`;
51
- md += `| **Engagements** | ${formatNumber(totals.engagements)} |\n`;
52
- md += `| **Likes** | ${formatNumber(totals.likes)} |\n`;
53
- md += `| **Comments** | ${formatNumber(totals.comments)} |\n`;
54
- md += `| **Shares** | ${formatNumber(totals.shares)} |\n`;
55
- if (totals.impressions > 0) {
56
- const rate = ((totals.engagements / totals.impressions) * 100).toFixed(2);
57
- md += `| **Engagement Rate** | ${rate}% |\n`;
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
- md += `| ${capitalize(p.platform)} | ${formatNumber(p.impressions)} | ${formatNumber(p.engagements)} | ${formatNumber(p.likes)} | ${formatNumber(p.comments)} | ${formatNumber(p.shares)} |\n`;
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
- for (const [platform, p] of platformEntries) {
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
- const imp = platform === "instagram"
94
- ? Number(m.reach ?? m.impressions ?? 0)
95
- : Number(m.views ?? m.impressions ?? 0);
96
- const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
97
- const comments = Number(m.comments ?? m.replies ?? 0);
98
- const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
99
- const eng = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
100
- impressions += imp;
101
- engagements += eng;
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 += `|---------|-----------|-------------|-------------|\n`;
141
+ md += `| Post ID | Platforms | Impressions | Engagements | Likes | Comments | Shares |\n`;
142
+ md += `|---------|-----------|-------------|-------------|-------|----------|--------|\n`;
115
143
  for (const r of rows) {
116
- md += `| ${r.post_id} | ${r.platformNames || "—"} | ${r.platformCount ? formatNumber(r.impressions) : "—"} | ${r.platformCount ? formatNumber(r.engagements) : "—"} |\n`;
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
  };
@@ -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
@@ -812,7 +812,7 @@ Notes:
812
812
  });
813
813
  return { content: [{ type: "text", text: md }] };
814
814
  });
815
- 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` and `impressions` so you can rank across platforms (Instagram posts include 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); LinkedIn, Threads, Pinterest, and Google Business return captions only. Fetched live, so expect a few seconds of latency. Requires the analytics:read scope.", {
815
+ 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
816
  limit: z
817
817
  .string()
818
818
  .optional()
@@ -841,15 +841,20 @@ Notes:
841
841
  };
842
842
  }
843
843
  let md = `## Recent platform posts (${posts.length} across ${connected.length} platform${connected.length === 1 ? "" : "s"})\n\n`;
844
- md += `| # | Platform | Format | Content | Engagement | Impressions | Date |\n`;
845
- md += `|---|----------|--------|---------|------------|-------------|------|\n`;
844
+ md += `| # | Platform | Format | Content | Engagement | Metrics | Date |\n`;
845
+ md += `|---|----------|--------|---------|------------|---------|------|\n`;
846
846
  posts.forEach((p, i) => {
847
847
  const hasMetrics = p.metrics && Object.keys(p.metrics).length > 0;
848
848
  const content = truncate(clean(p.text), 45) || "*(no caption)*";
849
849
  const eng = hasMetrics ? formatNumber(p.engagement || 0) : "—";
850
- const imp = hasMetrics ? formatNumber(p.impressions || 0) : "—";
850
+ // Every counter the platform reported, compact: "1.4K views · 12 saves".
851
+ // Engagement has its own column, so skip its row here.
852
+ const detail = metricRows(p.metrics)
853
+ .filter(([label]) => label !== "Engagements")
854
+ .map(([label, n]) => `${formatNumber(n)} ${label.toLowerCase()}`)
855
+ .join(" · ");
851
856
  const date = p.timestamp ? formatDateTime(p.timestamp) : "—";
852
- md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${imp} | ${date} |\n`;
857
+ md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${detail || "—"} | ${date} |\n`;
853
858
  });
854
859
  if (result.note)
855
860
  md += `\n${result.note}\n`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
5
5
  "type": "module",
6
6
  "main": "build/index.js",