@omnisocials/mcp-server 1.9.0 → 1.10.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
@@ -238,6 +238,10 @@ export declare class OmniSocialsClient {
238
238
  platform?: string;
239
239
  date?: string;
240
240
  }): Promise<ApiResponse<unknown>>;
241
+ getBestTimes(params: {
242
+ platform: string;
243
+ timezone?: string;
244
+ }): Promise<ApiResponse<unknown>>;
241
245
  listWebhooks(): Promise<ApiResponse<unknown>>;
242
246
  createWebhook(data: {
243
247
  url: string;
package/build/client.js CHANGED
@@ -224,6 +224,9 @@ export class OmniSocialsClient {
224
224
  async getAccountAnalytics(params) {
225
225
  return this.request("GET", "/analytics/accounts", undefined, params);
226
226
  }
227
+ async getBestTimes(params) {
228
+ return this.request("GET", "/analytics/best-times", undefined, params);
229
+ }
227
230
  // Webhooks
228
231
  async listWebhooks() {
229
232
  return this.request("GET", "/webhooks");
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.9.0",
30
+ version: "1.10.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);
@@ -221,4 +221,57 @@ export function registerAnalyticsTools(server, getClient) {
221
221
  content: [{ type: "text", text: md }],
222
222
  };
223
223
  });
224
+ server.tool("get_best_times", "Get recommended posting times (day + hour) for one platform, computed from the workspace's own posting history: publish time × engagement of every post published there, recency-weighted and outlier-damped, bucketed in the user's timezone. Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, industry-average defaults are returned instead (`basis: defaults`) with how many more posts unlock personalized recommendations — tell the user that so the numbers aren't mistaken for their own audience data. Use this before scheduling when the user asks 'when should I post?' or hasn't specified a time. Requires the analytics:read scope.", {
225
+ platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business"),
226
+ timezone: z.string().optional().describe("IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone."),
227
+ }, async (params) => {
228
+ const result = await getClient().getBestTimes(params);
229
+ if (result.error) {
230
+ return {
231
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
232
+ };
233
+ }
234
+ const d = result;
235
+ let md = `## Best times to post — ${capitalize(d.platform)} (${d.timezone})\n\n`;
236
+ md += `**Top slots:**\n`;
237
+ (d.recommendations || []).forEach((r, i) => {
238
+ const extras = [];
239
+ if (r.typical_engagement)
240
+ extras.push(`~${formatNumber(r.typical_engagement)} typical engagement`);
241
+ if (r.n)
242
+ extras.push(`${r.n} posts`);
243
+ md += `${i + 1}. **${capitalize(r.day)} ${r.time}** (score ${r.score}${extras.length ? `, ${extras.join(", ")}` : ""})\n`;
244
+ });
245
+ // Per-day compact breakdown: up to 2 best hours per day, in week order.
246
+ const byDay = new Map();
247
+ for (const cell of d.grid || []) {
248
+ if (!byDay.has(cell.day))
249
+ byDay.set(cell.day, []);
250
+ byDay.get(cell.day).push(cell);
251
+ }
252
+ const weekOrder = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"];
253
+ const dayRows = weekOrder
254
+ .filter((day) => byDay.has(day))
255
+ .map((day) => {
256
+ const top = byDay
257
+ .get(day)
258
+ .sort((a, b) => b.score - a.score)
259
+ .slice(0, 2)
260
+ .map((c) => `${String(c.hour).padStart(2, "0")}:00 (${c.score})`)
261
+ .join(", ");
262
+ return `| ${capitalize(day)} | ${top} |`;
263
+ });
264
+ if (dayRows.length) {
265
+ md += `\n### Best hours per day (score 0-100)\n\n| Day | Best hours |\n|-----|------------|\n${dayRows.join("\n")}\n`;
266
+ }
267
+ if (d.basis === "defaults") {
268
+ 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`;
269
+ }
270
+ else {
271
+ 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`;
272
+ }
273
+ return {
274
+ content: [{ type: "text", text: md }],
275
+ };
276
+ });
224
277
  }
@@ -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. 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, 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);
@@ -171,10 +171,45 @@ export function registerPostTools(server, getClient) {
171
171
  md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
172
172
  if (appUrl)
173
173
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
174
- if (p.media?.length) {
175
- md += `| **Media** | ${p.media.length} file(s) |\n`;
174
+ // Media can be a flat array (same set everywhere) or a per-platform
175
+ // object ({ default: [...], instagram: [...] }). The old `p.media.length`
176
+ // check silently dropped the object shape (`.length` is undefined).
177
+ const mediaGroups = Array.isArray(p.media)
178
+ ? (p.media.length ? [["all", p.media]] : [])
179
+ : Object.entries(p.media || {}).filter((e) => Array.isArray(e[1]) && e[1].length > 0);
180
+ const mediaUrlOf = (m) => (typeof m === "string" ? m : m?.url) || null;
181
+ const uniqueMediaUrls = new Set(mediaGroups.flatMap(([, items]) => items.map(mediaUrlOf).filter(Boolean)));
182
+ if (uniqueMediaUrls.size) {
183
+ md += `| **Media** | ${uniqueMediaUrls.size} file(s) |\n`;
184
+ }
185
+ // Instagram extras, echoed top-level by the API to mirror create_post.
186
+ if (p.location_id)
187
+ md += `| **IG location** | \`${p.location_id}\` |\n`;
188
+ if (Array.isArray(p.collaborators) && p.collaborators.length) {
189
+ md += `| **IG collaborators** | ${p.collaborators.map((c) => `@${c}`).join(", ")} |\n`;
190
+ }
191
+ if (Array.isArray(p.user_tags) && p.user_tags.length) {
192
+ md += `| **IG user tags** | ${p.user_tags
193
+ .map((t) => `@${typeof t === "string" ? t : t?.username || "?"}`)
194
+ .join(", ")} |\n`;
176
195
  }
177
196
  md += `\n### Content\n\n${renderContent(p.content)}`;
197
+ // Media URLs — so a caller can verify exactly what's attached (and in
198
+ // what order) without opening the dashboard.
199
+ if (mediaGroups.length) {
200
+ md += `\n\n### Media\n\n`;
201
+ const flat = mediaGroups.length === 1 && mediaGroups[0][0] === "all";
202
+ for (const [group, items] of mediaGroups) {
203
+ if (!flat) {
204
+ md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
205
+ }
206
+ items.forEach((m, i) => {
207
+ md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}\n`;
208
+ });
209
+ md += `\n`;
210
+ }
211
+ md = md.trimEnd();
212
+ }
178
213
  // X thread: when present, the canonical tweet text lives here, not in `content`.
179
214
  const threadParts = Array.isArray(p.x?.thread_parts)
180
215
  ? p.x.thread_parts
@@ -242,13 +277,15 @@ export function registerPostTools(server, getClient) {
242
277
  const firstCommentRows = [];
243
278
  for (const platform of firstCommentPlatforms) {
244
279
  const opts = p[platform];
245
- const text = opts && typeof opts === "object" ? opts.first_comment : undefined;
246
- if (typeof text !== "string" || !text.trim())
280
+ if (!opts || typeof opts !== "object")
247
281
  continue;
282
+ const text = typeof opts.first_comment === "string" ? opts.first_comment.trim() : "";
248
283
  const result = opts.first_comment_result;
284
+ if (!text && !result)
285
+ continue;
249
286
  const status = result && typeof result === "object" ? result.status : undefined;
250
287
  const statusLabel = status ? ` — _${status}${result?.error ? `: ${result.error}` : ""}_` : "";
251
- firstCommentRows.push(`- **${capitalize(platform.replace(/_/g, " "))}**${statusLabel}: ${truncate(text, 200)}`);
288
+ firstCommentRows.push(`- **${capitalize(platform.replace(/_/g, " "))}**${statusLabel}: ${text ? truncate(text, 200) : "*(none)*"}`);
252
289
  }
253
290
  if (firstCommentRows.length) {
254
291
  md += `\n\n### First Comments\n\n${firstCommentRows.join("\n")}\n`;
@@ -775,7 +812,7 @@ Notes:
775
812
  });
776
813
  return { content: [{ type: "text", text: md }] };
777
814
  });
778
- 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. 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` 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.", {
779
816
  limit: z
780
817
  .string()
781
818
  .optional()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.9.0",
3
+ "version": "1.10.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",