@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 CHANGED
@@ -218,6 +218,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
218
218
 
219
219
  ## Changelog
220
220
 
221
+ ### 1.20.0 (2026-08-04)
222
+
223
+ - **Engagement rate now matches the dashboard:** `get_post_analytics` / `get_posts_analytics` previously summed likes + comments + shares only, dropping LinkedIn link clicks and X quotes/bookmarks from engagement. Both tools now use the same normalization as the dashboard and `get_analytics_overview`; rates are suppressed below 10 impressions and capped at 100%.
224
+ - **Account metric semantics clarified:** LinkedIn account-level `impressions` on `get_account_analytics` are lifetime totals across all of the account's content (not a windowed count) — the tool now says so and renders each platform's scope `note`.
225
+
221
226
  ### 1.19.0 (2026-08-02)
222
227
 
223
228
  - **Alt text now reaches Instagram and LinkedIn:** per-media alt text (`{ url, alt }` / `{ id, alt }` entries) is also delivered to Instagram (image posts and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents), alongside Mastodon, Bluesky, X, and Pinterest.
package/build/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ApiResponse, MediaItem, PdfUploadResult, InboxConversation, InboxMessage } from "./types.js";
1
+ import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, InboxConversation, InboxMessage, Webhook } from "./types.js";
2
2
  export declare function fetchImageAsBase64(url: string): Promise<{
3
3
  data: string;
4
4
  mimeType: string;
@@ -100,6 +100,45 @@ export interface MastodonPostOptions {
100
100
  export interface MastodonPostOptionsUpdate {
101
101
  thread_parts?: MastodonThreadPartInput[] | null;
102
102
  }
103
+ /**
104
+ * Request body for POST /posts/create. `createAndPublishPost` accepts the
105
+ * same shape minus `scheduled_at` (publishing is immediate).
106
+ */
107
+ export interface CreatePostInput {
108
+ content: string | Record<string, string>;
109
+ channels?: string[];
110
+ scheduled_at?: string;
111
+ media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
112
+ media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
113
+ type?: string;
114
+ source?: string;
115
+ link_url?: string;
116
+ link_title?: string;
117
+ link_description?: string;
118
+ link_thumbnail_url?: string;
119
+ location_id?: string;
120
+ collaborators?: string[];
121
+ user_tags?: Array<{
122
+ username: string;
123
+ x: number;
124
+ y: number;
125
+ image_index?: number;
126
+ }>;
127
+ pinterest?: Record<string, unknown>;
128
+ youtube?: Record<string, unknown>;
129
+ instagram?: Record<string, unknown>;
130
+ facebook?: Record<string, unknown>;
131
+ linkedin?: Record<string, unknown>;
132
+ linkedin_page?: Record<string, unknown>;
133
+ tiktok?: Record<string, unknown>;
134
+ x?: XPostOptions;
135
+ bluesky?: BlueskyPostOptions;
136
+ mastodon?: MastodonPostOptions;
137
+ google_business?: Record<string, unknown>;
138
+ hashtag_set?: string;
139
+ hashtag_placement?: "caption_append" | "first_comment";
140
+ hashtag_platforms?: string[];
141
+ }
103
142
  export declare class OmniSocialsClient {
104
143
  private baseUrl;
105
144
  private apiKey;
@@ -109,81 +148,20 @@ export declare class OmniSocialsClient {
109
148
  status?: string;
110
149
  limit?: string;
111
150
  offset?: string;
112
- }): Promise<ApiResponse<unknown>>;
113
- getPost(id: string): Promise<ApiResponse<unknown>>;
151
+ }): Promise<ApiResponse<ApiPost[]>>;
152
+ getPost(id: string): Promise<ApiResponse<ApiPost>>;
114
153
  getRecentPlatformPosts(params?: {
115
154
  limit?: string;
116
155
  platforms?: string;
117
- }): Promise<ApiResponse<unknown>>;
118
- createPost(data: {
119
- content: string | Record<string, string>;
120
- channels?: string[];
121
- scheduled_at?: string;
122
- media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
123
- media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
124
- type?: string;
125
- source?: string;
126
- link_url?: string;
127
- link_title?: string;
128
- link_description?: string;
129
- link_thumbnail_url?: string;
130
- location_id?: string;
131
- collaborators?: string[];
132
- user_tags?: Array<{
133
- username: string;
134
- x: number;
135
- y: number;
136
- image_index?: number;
137
- }>;
138
- pinterest?: Record<string, unknown>;
139
- youtube?: Record<string, unknown>;
140
- instagram?: Record<string, unknown>;
141
- facebook?: Record<string, unknown>;
142
- linkedin?: Record<string, unknown>;
143
- linkedin_page?: Record<string, unknown>;
144
- tiktok?: Record<string, unknown>;
145
- x?: XPostOptions;
146
- bluesky?: BlueskyPostOptions;
147
- mastodon?: MastodonPostOptions;
148
- google_business?: Record<string, unknown>;
149
- hashtag_set?: string;
150
- hashtag_placement?: "caption_append" | "first_comment";
151
- hashtag_platforms?: string[];
152
- }): Promise<ApiResponse<unknown>>;
153
- createAndPublishPost(data: {
154
- content: string | Record<string, string>;
155
- channels?: string[];
156
- media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
157
- media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
158
- type?: string;
159
- source?: string;
160
- link_url?: string;
161
- link_title?: string;
162
- link_description?: string;
163
- link_thumbnail_url?: string;
164
- location_id?: string;
165
- collaborators?: string[];
166
- user_tags?: Array<{
167
- username: string;
168
- x: number;
169
- y: number;
170
- image_index?: number;
171
- }>;
172
- pinterest?: Record<string, unknown>;
173
- youtube?: Record<string, unknown>;
174
- instagram?: Record<string, unknown>;
175
- facebook?: Record<string, unknown>;
176
- linkedin?: Record<string, unknown>;
177
- linkedin_page?: Record<string, unknown>;
178
- tiktok?: Record<string, unknown>;
179
- x?: XPostOptions;
180
- bluesky?: BlueskyPostOptions;
181
- mastodon?: MastodonPostOptions;
182
- google_business?: Record<string, unknown>;
183
- hashtag_set?: string;
184
- hashtag_placement?: "caption_append" | "first_comment";
185
- hashtag_platforms?: string[];
186
- }): Promise<ApiResponse<unknown>>;
156
+ }): Promise<ApiResponse<RecentPlatformPost[]> & {
157
+ count?: number;
158
+ connected_platforms?: string[];
159
+ errors?: Record<string, string>;
160
+ note?: string;
161
+ current_date?: string;
162
+ }>;
163
+ createPost(data: CreatePostInput): Promise<ApiResponse<ApiPost>>;
164
+ createAndPublishPost(data: Omit<CreatePostInput, "scheduled_at">): Promise<ApiResponse<ApiPost>>;
187
165
  updatePost(id: string, data: {
188
166
  content?: string | Record<string, string>;
189
167
  scheduled_at?: string;
@@ -210,26 +188,26 @@ export declare class OmniSocialsClient {
210
188
  bluesky?: BlueskyPostOptionsUpdate;
211
189
  mastodon?: MastodonPostOptionsUpdate;
212
190
  google_business?: Record<string, unknown>;
213
- }): Promise<ApiResponse<unknown>>;
191
+ }): Promise<ApiResponse<ApiPost>>;
214
192
  deletePost(id: string): Promise<ApiResponse<unknown>>;
215
- publishPost(id: string): Promise<ApiResponse<unknown>>;
216
- retryPost(id: string): Promise<ApiResponse<unknown>>;
217
- searchLocations(query: string): Promise<ApiResponse<unknown>>;
193
+ publishPost(id: string): Promise<ApiResponse<PublishPostResult>>;
194
+ retryPost(id: string): Promise<ApiResponse<RetryPostResult>>;
195
+ searchLocations(query: string): Promise<LocationSearchResult>;
218
196
  validateLocation(id: string): Promise<ApiResponse<unknown>>;
219
- searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<ApiResponse<unknown>>;
197
+ searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<AudioSearchResult>;
220
198
  listMedia(params?: {
221
199
  limit?: string;
222
200
  offset?: string;
223
201
  search?: string;
224
202
  folder_id?: string;
225
- }): Promise<ApiResponse<unknown>>;
203
+ }): Promise<ApiResponse<MediaItem[]>>;
226
204
  uploadMedia(data: {
227
205
  url: string;
228
206
  filename?: string;
229
207
  name?: string;
230
208
  folder?: string;
231
209
  }): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
232
- compatibility?: unknown;
210
+ compatibility?: MediaCompatibility;
233
211
  message?: string;
234
212
  }>;
235
213
  uploadMediaFromBase64(data: {
@@ -239,7 +217,7 @@ export declare class OmniSocialsClient {
239
217
  name?: string;
240
218
  folder?: string;
241
219
  }): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
242
- compatibility?: unknown;
220
+ compatibility?: MediaCompatibility;
243
221
  message?: string;
244
222
  }>;
245
223
  /**
@@ -252,57 +230,73 @@ export declare class OmniSocialsClient {
252
230
  media_id?: string;
253
231
  size_bytes?: number;
254
232
  mime?: string;
255
- }): Promise<ApiResponse<unknown>>;
233
+ }): Promise<MediaCheckResult>;
256
234
  deleteMedia(id: string): Promise<ApiResponse<unknown>>;
257
235
  updateMedia(id: string, data: {
258
236
  name?: string;
259
237
  folder_id?: string | null;
260
- }): Promise<ApiResponse<unknown>>;
261
- listFolders(): Promise<ApiResponse<unknown>>;
238
+ }): Promise<ApiResponse<MediaItem>>;
239
+ listFolders(): Promise<ApiResponse<MediaFolder[]>>;
262
240
  createFolder(data: {
263
241
  name: string;
264
242
  parent_id?: string;
265
- }): Promise<ApiResponse<unknown>>;
266
- listHashtagSets(): Promise<ApiResponse<unknown>>;
243
+ }): Promise<ApiResponse<MediaFolder>>;
244
+ listHashtagSets(): Promise<ApiResponse<HashtagSet[]>>;
267
245
  createHashtagSet(data: {
268
246
  name: string;
269
247
  hashtags: string[];
270
- }): Promise<ApiResponse<unknown>>;
248
+ }): Promise<ApiResponse<HashtagSet>>;
271
249
  updateHashtagSet(id: string, data: {
272
250
  name?: string;
273
251
  hashtags?: string[];
274
- }): Promise<ApiResponse<unknown>>;
252
+ }): Promise<ApiResponse<HashtagSet>>;
275
253
  deleteHashtagSet(id: string): Promise<ApiResponse<unknown>>;
276
- listAccounts(): Promise<ApiResponse<unknown>>;
277
- getAccount(id: string): Promise<ApiResponse<unknown>>;
278
- getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
279
- getPostsAnalytics(postIds: string[]): Promise<ApiResponse<unknown>>;
254
+ listAccounts(): Promise<ApiResponse<Account[]> & {
255
+ workspace_id?: number | string | null;
256
+ workspace_name?: string | null;
257
+ workspace_icon?: string | null;
258
+ }>;
259
+ getAccount(id: string): Promise<ApiResponse<Account>>;
260
+ getPostAnalytics(postId: string): Promise<ApiResponse<PostAnalytics>>;
261
+ getPostsAnalytics(postIds: string[]): Promise<ApiResponse<PostAnalytics[]>>;
280
262
  getAnalyticsOverview(params?: {
281
263
  period?: string;
282
264
  start_date?: string;
283
265
  end_date?: string;
284
- }): Promise<ApiResponse<unknown>>;
266
+ }): Promise<ApiResponse<AnalyticsOverview> & {
267
+ period?: string;
268
+ start_date?: string;
269
+ end_date?: string;
270
+ current_date?: string;
271
+ }>;
285
272
  getAccountAnalytics(params?: {
286
273
  platform?: string;
287
274
  date?: string;
288
- }): Promise<ApiResponse<unknown>>;
275
+ }): Promise<ApiResponse<AccountAnalyticsEntry[]> & {
276
+ date?: string;
277
+ }>;
289
278
  getBestTimes(params: {
290
279
  platform: string;
291
280
  timezone?: string;
292
- }): Promise<ApiResponse<unknown>>;
293
- listWebhooks(): Promise<ApiResponse<unknown>>;
281
+ }): Promise<BestTimesResult>;
282
+ listWebhooks(): Promise<ApiResponse<Webhook[]>>;
294
283
  createWebhook(data: {
295
284
  url: string;
296
285
  events: string[];
297
- }): Promise<ApiResponse<unknown>>;
286
+ }): Promise<ApiResponse<Webhook & {
287
+ secret: string;
288
+ }>>;
298
289
  deleteWebhook(id: string): Promise<ApiResponse<unknown>>;
299
- getWebhook(id: string): Promise<ApiResponse<unknown>>;
290
+ getWebhook(id: string): Promise<ApiResponse<Webhook>>;
300
291
  updateWebhook(id: string, data: {
301
292
  url?: string;
302
293
  events?: string[];
303
294
  is_active?: boolean;
304
- }): Promise<ApiResponse<unknown>>;
305
- rotateWebhookSecret(id: string): Promise<ApiResponse<unknown>>;
295
+ }): Promise<ApiResponse<Webhook>>;
296
+ rotateWebhookSecret(id: string): Promise<ApiResponse<{
297
+ id: string;
298
+ secret: string;
299
+ }>>;
306
300
  listInboxConversations(params?: {
307
301
  platform?: string;
308
302
  type?: string;
package/build/client.js CHANGED
@@ -201,6 +201,8 @@ export class OmniSocialsClient {
201
201
  // Recent posts fetched live from the connected platform APIs (including
202
202
  // content published outside OmniSocials). The fallback for brand-new
203
203
  // workspaces where listPosts is empty. Requires the analytics:read scope.
204
+ // `connected_platforms`/`errors`/`note`/`current_date` ride top-level on the
205
+ // envelope (backend/routes/api/v1/posts.js recent-platform route).
204
206
  async getRecentPlatformPosts(params) {
205
207
  return this.request("GET", "/posts/recent-platform", undefined, params);
206
208
  }
@@ -224,14 +226,17 @@ export class OmniSocialsClient {
224
226
  async retryPost(id) {
225
227
  return this.request("POST", `/posts/${id}/retry`);
226
228
  }
227
- // Locations (Instagram place tagging)
229
+ // Locations (Instagram place tagging). NOT the standard envelope — see
230
+ // LocationSearchResult (degraded-path `error` is a plain string).
228
231
  async searchLocations(query) {
229
- return this.request("GET", "/locations/search", undefined, { q: query });
232
+ return this.request("GET", "/locations/search", undefined, {
233
+ q: query,
234
+ });
230
235
  }
231
236
  async validateLocation(id) {
232
237
  return this.request("GET", "/locations/validate", undefined, { id });
233
238
  }
234
- // Audio (Instagram Reel music)
239
+ // Audio (Instagram Reel music). Same nonstandard string-`error` envelope.
235
240
  async searchInstagramAudio(query, type) {
236
241
  const params = {};
237
242
  if (query)
@@ -262,6 +267,7 @@ export class OmniSocialsClient {
262
267
  * or `size_bytes` + `mime`.
263
268
  */
264
269
  async checkMediaCompatibility(data) {
270
+ // Nonstandard envelope: compatibility fields ride top-level, no `data`.
265
271
  return this.request("POST", "/media/check", data);
266
272
  }
267
273
  async deleteMedia(id) {
@@ -290,7 +296,9 @@ export class OmniSocialsClient {
290
296
  async deleteHashtagSet(id) {
291
297
  return this.request("DELETE", `/hashtag-sets/${id}`);
292
298
  }
293
- // Accounts
299
+ // Accounts. Alongside `data`, the list response carries the workspace's
300
+ // identity top-level (backend/routes/api/v1/accounts.js) — the workspaces
301
+ // tool reads it to label multi-workspace setups.
294
302
  async listAccounts() {
295
303
  return this.request("GET", "/accounts");
296
304
  }
@@ -308,12 +316,18 @@ export class OmniSocialsClient {
308
316
  ids: postIds.join(","),
309
317
  });
310
318
  }
319
+ // The resolved range and today's date ride top-level on the envelope
320
+ // (backend/routes/api/v1/analytics.js overview route) so clients can render
321
+ // an accurate "as of <today>" line.
311
322
  async getAnalyticsOverview(params) {
312
323
  return this.request("GET", "/analytics/overview", undefined, params);
313
324
  }
325
+ // The resolved `date` rides top-level on the envelope.
314
326
  async getAccountAnalytics(params) {
315
327
  return this.request("GET", "/analytics/accounts", undefined, params);
316
328
  }
329
+ // Nonstandard envelope: the result object is returned top-level, no `data`
330
+ // (backend/routes/api/v1/analytics.js best-times route).
317
331
  async getBestTimes(params) {
318
332
  return this.request("GET", "/analytics/best-times", undefined, params);
319
333
  }
@@ -321,6 +335,7 @@ export class OmniSocialsClient {
321
335
  async listWebhooks() {
322
336
  return this.request("GET", "/webhooks");
323
337
  }
338
+ // The signing secret is only ever returned at creation time.
324
339
  async createWebhook(data) {
325
340
  return this.request("POST", "/webhooks", data);
326
341
  }
@@ -333,6 +348,8 @@ export class OmniSocialsClient {
333
348
  async updateWebhook(id, data) {
334
349
  return this.request("PATCH", `/webhooks/${id}`, data);
335
350
  }
351
+ // Returns only { id, secret } — not the full webhook
352
+ // (backend/routes/api/v1/webhooks.js rotate-secret route).
336
353
  async rotateWebhookSecret(id) {
337
354
  return this.request("POST", `/webhooks/${id}/rotate-secret`);
338
355
  }
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.19.0",
32
+ version: "1.20.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);
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Engagement / impressions normalizers — VENDORED COPY of the repo's
3
+ * `shared/analytics-metrics/index.js` (the single source of truth used by the
4
+ * backend, public API, remote MCP, and dashboard). This package publishes to
5
+ * npm standalone, so it can't require() across the monorepo — keep this file
6
+ * in sync with shared/analytics-metrics when the rules change there.
7
+ *
8
+ * WHY: the MCP tools used to compute engagement inline as
9
+ * likes+comments+shares, which silently dropped LinkedIn clicks and X
10
+ * quotes/bookmarks — so the same post showed a different engagement rate in
11
+ * MCP output than on the dashboard or /analytics/overview.
12
+ */
13
+ export declare const MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE = 10;
14
+ type Metrics = Record<string, unknown> | null | undefined;
15
+ /** Total engagement (interaction count) for a post's metrics on a platform. */
16
+ export declare function getEngagement(platform: string, metrics: Metrics): number;
17
+ /** Best-available impressions ("how many times seen") for a post's metrics. */
18
+ export declare function getImpressions(platform: string, metrics: Metrics): number;
19
+ /**
20
+ * Engagement rate as a percentage (0-100), suppressed below the impression
21
+ * floor and capped at 100%.
22
+ */
23
+ export declare function getEngagementRate(engagement: number, impressions: number): number;
24
+ export {};
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Engagement / impressions normalizers — VENDORED COPY of the repo's
3
+ * `shared/analytics-metrics/index.js` (the single source of truth used by the
4
+ * backend, public API, remote MCP, and dashboard). This package publishes to
5
+ * npm standalone, so it can't require() across the monorepo — keep this file
6
+ * in sync with shared/analytics-metrics when the rules change there.
7
+ *
8
+ * WHY: the MCP tools used to compute engagement inline as
9
+ * likes+comments+shares, which silently dropped LinkedIn clicks and X
10
+ * quotes/bookmarks — so the same post showed a different engagement rate in
11
+ * MCP output than on the dashboard or /analytics/overview.
12
+ */
13
+ // Minimum impressions before an engagement-rate percentage is meaningful.
14
+ // Below this we suppress the rate rather than render a noisy ratio.
15
+ export const MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE = 10;
16
+ // Coerce anything (number, numeric string, null, undefined) to a finite number.
17
+ const num = (v) => {
18
+ if (typeof v === "number")
19
+ return Number.isFinite(v) ? v : 0;
20
+ const n = Number(v);
21
+ return Number.isFinite(n) ? n : 0;
22
+ };
23
+ /** Total engagement (interaction count) for a post's metrics on a platform. */
24
+ export function getEngagement(platform, metrics) {
25
+ const m = metrics || {};
26
+ // Trust a precomputed engagement count when a fetcher already supplied one
27
+ // (Instagram total_interactions, TikTok/Threads/Pinterest sums, Google
28
+ // Business account engagement). Only when it's a real positive number —
29
+ // a stored 0 means "not computed" for the platforms that don't set it.
30
+ const pre = num(m.engagement);
31
+ if (pre > 0)
32
+ return pre;
33
+ const sum = (...keys) => keys.reduce((t, k) => t + num(m[k]), 0);
34
+ switch (platform) {
35
+ case "facebook":
36
+ // total_reactions already aggregates like/love/wow/...; fall back to the
37
+ // bare like count only when the aggregate is absent (never add both).
38
+ return (num(m.total_reactions) || num(m.likes)) + num(m.comments) + num(m.shares);
39
+ case "linkedin":
40
+ case "linkedin_page":
41
+ // LinkedIn's native engagement includes link clicks.
42
+ return sum("likes", "comments", "shares", "clicks");
43
+ case "youtube":
44
+ return sum("likes", "comments", "shares");
45
+ case "x":
46
+ // X's engagement total includes bookmarks (saves).
47
+ return sum("likes", "comments", "reposts", "quotes", "bookmarks");
48
+ case "threads":
49
+ // Threads stores replies (not comments) as the comment-equivalent.
50
+ return num(m.likes) + (num(m.comments) || num(m.replies)) + num(m.reposts) + num(m.quotes);
51
+ case "bluesky":
52
+ return sum("likes", "comments", "reposts", "quotes");
53
+ case "mastodon":
54
+ return sum("likes", "comments", "reposts");
55
+ case "reddit":
56
+ // likes == upvote score; shares == crossposts.
57
+ return sum("likes", "comments", "shares");
58
+ case "instagram":
59
+ return sum("likes", "comments", "saves", "shares");
60
+ case "tiktok":
61
+ return sum("likes", "comments", "shares");
62
+ case "pinterest":
63
+ return sum("saves", "pin_clicks", "outbound_clicks");
64
+ case "google_business":
65
+ // engagement is precomputed at account level; nothing to sum per post.
66
+ return pre;
67
+ default:
68
+ // Generic safe sum of common interaction fields for any future platform.
69
+ return sum("likes", "comments", "replies", "shares", "reposts", "quotes", "saves");
70
+ }
71
+ }
72
+ /** Best-available impressions ("how many times seen") for a post's metrics. */
73
+ export function getImpressions(platform, metrics) {
74
+ const m = metrics || {};
75
+ switch (platform) {
76
+ case "instagram":
77
+ // Reels/video report `views` (play count) which is the true total-seen
78
+ // number and exceeds unique `reach`. Prefer it; fall back for old rows.
79
+ return num(m.views) || num(m.impressions) || num(m.reach);
80
+ case "facebook":
81
+ // Facebook exposes reach/impressions; video posts also report video_views.
82
+ return num(m.reach) || num(m.impressions) || num(m.video_views) || num(m.views);
83
+ case "linkedin":
84
+ case "linkedin_page":
85
+ case "pinterest":
86
+ case "google_business":
87
+ return num(m.impressions) || num(m.views) || num(m.reach);
88
+ case "youtube":
89
+ case "tiktok":
90
+ case "x":
91
+ case "threads":
92
+ case "bluesky":
93
+ case "mastodon":
94
+ case "reddit":
95
+ default:
96
+ return num(m.views) || num(m.impressions) || num(m.reach);
97
+ }
98
+ }
99
+ /**
100
+ * Engagement rate as a percentage (0-100), suppressed below the impression
101
+ * floor and capped at 100%.
102
+ */
103
+ export function getEngagementRate(engagement, impressions) {
104
+ const imp = num(impressions);
105
+ if (imp < MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE)
106
+ return 0;
107
+ const rate = (num(engagement) / imp) * 100;
108
+ if (!Number.isFinite(rate) || rate < 0)
109
+ return 0;
110
+ return Math.min(rate, 100);
111
+ }
@@ -8,7 +8,7 @@ export function registerAccountTools(server, getClient) {
8
8
  content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
9
9
  };
10
10
  }
11
- const accounts = Array.isArray(result.data) ? result.data : result.data?.accounts || [];
11
+ const accounts = Array.isArray(result.data) ? result.data : [];
12
12
  if (!accounts.length) {
13
13
  return {
14
14
  content: [{ type: "text", text: "No connected accounts found." }],
@@ -42,7 +42,7 @@ export function registerAccountTools(server, getClient) {
42
42
  // Fetch profile pictures
43
43
  const content = [];
44
44
  const imagePromises = accounts
45
- .filter((a) => a.profile_picture)
45
+ .filter((a) => !!a.profile_picture)
46
46
  .slice(0, 6)
47
47
  .map(async (a) => {
48
48
  const img = await fetchImageAsBase64(a.profile_picture);