@omnisocials/mcp-server 1.3.6 → 1.3.8

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
@@ -191,6 +191,16 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
191
191
 
192
192
  ## Changelog
193
193
 
194
+ ### 1.3.8
195
+
196
+ - **Fixed:** Pinterest pins created via `create_post` / `create_and_publish_post` / `update_post` now publish with title, link, video cover, and alt text. Companion server fix — the API was storing those fields under unprefixed keys while the publisher read them under `pinterest_*` prefixes, so pins published with no metadata even when the agent passed it. Existing clients on 1.3.7 benefit automatically once the backend deploys.
197
+ - **Added:** `pinterest.video_cover` and `pinterest.alt_text` to the tool schemas. `alt_text` improves accessibility + Pinterest's discoverability for visually impaired users; `video_cover` sets a custom cover image for video pins (otherwise Pinterest uses a 1s video keyframe).
198
+
199
+ ### 1.3.7
200
+
201
+ - **Fixed:** `update_post` now accepts per-platform options (`youtube`, `pinterest`, `instagram`, `tiktok`) — same shape as `create_post`. Previously these were missing from the `update_post` schema, so agents that tried to rename a scheduled YouTube Short via `youtube.title` had the field silently stripped before it reached the server. Renaming a Short, changing privacy, adjusting a Pinterest board, etc. on an existing draft now work.
202
+ - Tool descriptions clarify that `content` is the post caption / body, and for YouTube Shorts becomes the video **description** — **not** the Short's title. To rename a Short, use `youtube.title`.
203
+
194
204
  ### 1.3.6
195
205
 
196
206
  - Metadata-only republish of 1.3.5 to refresh the README on npmjs.com. No code changes from 1.3.5.
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { fetchImageAsBase64, capitalize } from "../client.js";
3
3
  export function registerAccountTools(server, getClient) {
4
- server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), and supported content_types (post, story, reel). Pinterest accounts also include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
4
+ server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
5
5
  const result = await getClient().listAccounts();
6
6
  if (result.error) {
7
7
  return {
@@ -16,8 +16,8 @@ export function registerAccountTools(server, getClient) {
16
16
  }
17
17
  // Build markdown table
18
18
  let md = `## Connected Accounts (${accounts.length})\n\n`;
19
- md += `| # | Platform | Account | Content Types | Channel ID |\n`;
20
- md += `|---|----------|---------|---------------|------------|\n`;
19
+ md += `| # | Platform | Account | Content Types | Channel ID | Status |\n`;
20
+ md += `|---|----------|---------|---------------|------------|--------|\n`;
21
21
  for (let i = 0; i < accounts.length; i++) {
22
22
  const a = accounts[i];
23
23
  const name = a.username ? `@${a.username}` : a.display_name || "—";
@@ -26,7 +26,16 @@ export function registerAccountTools(server, getClient) {
26
26
  const sub = a.platform_details?.subscription_type && a.platform_details.subscription_type !== "None"
27
27
  ? ` (${a.platform_details.subscription_type})`
28
28
  : "";
29
- md += `| ${i + 1} | ${platform}${sub} | ${name} | ${types} | \`${a.id}\` |\n`;
29
+ const statusCell = a.needs_reconnect
30
+ ? `⚠️ needs_reconnect`
31
+ : "active";
32
+ md += `| ${i + 1} | ${platform}${sub} | ${name} | ${types} | \`${a.id}\` | ${statusCell} |\n`;
33
+ }
34
+ const reconnects = accounts.filter((a) => a.needs_reconnect);
35
+ if (reconnects.length) {
36
+ md += `\n⚠️ ${reconnects.length} account(s) need reconnecting before posting will succeed: ${reconnects
37
+ .map((a) => capitalize(a.platform))
38
+ .join(", ")}. Ask the user to reconnect in OmniSocials (Settings → Organisation → Workspaces).\n`;
30
39
  }
31
40
  md += `\nUse the **Channel ID** as the \`channels\` parameter when creating posts.`;
32
41
  // Fetch profile pictures
@@ -74,7 +83,10 @@ export function registerAccountTools(server, getClient) {
74
83
  md += `| **Username** | ${a.username ? `@${a.username}` : "—"} |\n`;
75
84
  md += `| **Display Name** | ${a.display_name || "—"} |\n`;
76
85
  md += `| **Content Types** | ${(a.content_types || []).join(", ")} |\n`;
77
- md += `| **Status** | ${a.status} |\n`;
86
+ md += `| **Status** | ${a.needs_reconnect ? "⚠️ needs_reconnect" : a.status} |\n`;
87
+ if (a.needs_reconnect && a.reauth_reason) {
88
+ md += `| **Reconnect reason** | ${a.reauth_reason} |\n`;
89
+ }
78
90
  md += `| **Connected** | ${a.connected_at ? new Date(a.connected_at).toLocaleDateString() : "—"} |\n`;
79
91
  if (a.platform_details?.subscription_type) {
80
92
  md += `| **Subscription** | ${a.platform_details.subscription_type} |\n`;
@@ -11,48 +11,64 @@ export function registerAnalyticsTools(server, getClient) {
11
11
  };
12
12
  }
13
13
  const d = result.data;
14
- if (!d) {
14
+ // API returns { post_id, platforms: { <platform>: { metrics: {...} } } }.
15
+ // Aggregate the per-platform metrics into totals; the legacy flat shape
16
+ // (d.impressions / d.platform_stats) doesn't exist on this endpoint and
17
+ // reading those keys returned "—" for every field.
18
+ const platforms = d?.platforms || {};
19
+ const platformEntries = Object.entries(platforms);
20
+ if (platformEntries.length === 0) {
15
21
  return {
16
- content: [{ type: "text", text: "No analytics found for this post." }],
22
+ content: [{
23
+ type: "text",
24
+ text: "No analytics collected yet for this post. Stats are fetched periodically after publishing; check back in a few hours, or verify the post finished publishing successfully.",
25
+ }],
17
26
  };
18
27
  }
28
+ const totals = { impressions: 0, engagements: 0, likes: 0, comments: 0, shares: 0 };
29
+ const perPlatform = [];
30
+ for (const [platform, entry] of platformEntries) {
31
+ const m = entry?.metrics || {};
32
+ const impressions = platform === "instagram"
33
+ ? Number(m.reach ?? m.impressions ?? 0)
34
+ : Number(m.views ?? m.impressions ?? 0);
35
+ const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
36
+ const comments = Number(m.comments ?? m.replies ?? 0);
37
+ const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
38
+ const engagements = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
39
+ totals.impressions += impressions;
40
+ totals.engagements += engagements;
41
+ totals.likes += likes;
42
+ totals.comments += comments;
43
+ totals.shares += shares;
44
+ perPlatform.push({ platform, impressions, engagements, likes, comments, shares });
45
+ }
19
46
  let md = `## Post Analytics\n\n`;
20
47
  md += `| Metric | Value |\n`;
21
48
  md += `|--------|-------|\n`;
22
- md += `| **Impressions** | ${formatNumber(d.impressions ?? 0)} |\n`;
23
- md += `| **Engagements** | ${formatNumber(d.engagements ?? 0)} |\n`;
24
- md += `| **Likes** | ${formatNumber(d.likes ?? 0)} |\n`;
25
- md += `| **Comments** | ${formatNumber(d.comments ?? 0)} |\n`;
26
- md += `| **Shares** | ${formatNumber(d.shares ?? 0)} |\n`;
27
- if (d.impressions > 0) {
28
- const rate = ((d.engagements ?? 0) / d.impressions * 100).toFixed(2);
49
+ md += `| **Impressions** | ${formatNumber(totals.impressions)} |\n`;
50
+ md += `| **Engagements** | ${formatNumber(totals.engagements)} |\n`;
51
+ md += `| **Likes** | ${formatNumber(totals.likes)} |\n`;
52
+ md += `| **Comments** | ${formatNumber(totals.comments)} |\n`;
53
+ md += `| **Shares** | ${formatNumber(totals.shares)} |\n`;
54
+ if (totals.impressions > 0) {
55
+ const rate = ((totals.engagements / totals.impressions) * 100).toFixed(2);
29
56
  md += `| **Engagement Rate** | ${rate}% |\n`;
30
57
  }
31
- if (d.platform_stats && Object.keys(d.platform_stats).length > 0) {
32
- md += `\n### Per-Platform Breakdown\n\n`;
33
- for (const [platform, stats] of Object.entries(d.platform_stats)) {
34
- const s = stats;
35
- md += `**${capitalize(platform)}:** `;
36
- const parts = [];
37
- if (s.impressions !== undefined)
38
- parts.push(`${formatNumber(s.impressions)} impressions`);
39
- if (s.engagements !== undefined)
40
- parts.push(`${formatNumber(s.engagements)} engagements`);
41
- if (s.likes !== undefined)
42
- parts.push(`${formatNumber(s.likes)} likes`);
43
- if (s.comments !== undefined)
44
- parts.push(`${formatNumber(s.comments)} comments`);
45
- md += parts.join(", ") + "\n\n";
46
- }
58
+ md += `\n### Per-Platform Breakdown\n\n`;
59
+ md += `| Platform | Impressions | Engagements | Likes | Comments | Shares |\n`;
60
+ md += `|----------|-------------|-------------|-------|----------|--------|\n`;
61
+ for (const p of perPlatform) {
62
+ md += `| ${capitalize(p.platform)} | ${formatNumber(p.impressions)} | ${formatNumber(p.engagements)} | ${formatNumber(p.likes)} | ${formatNumber(p.comments)} | ${formatNumber(p.shares)} |\n`;
47
63
  }
48
64
  return {
49
65
  content: [{ type: "text", text: md }],
50
66
  };
51
67
  });
52
- server.tool("get_analytics_overview", "Get an overview of analytics across all posts and accounts for a time period.", {
53
- period: z.string().optional().describe("Time period: 7d, 30d, 90d (default: 30d)"),
54
- start_date: z.string().optional().describe("Custom start date (ISO 8601)"),
55
- end_date: z.string().optional().describe("Custom end date (ISO 8601)"),
68
+ server.tool("get_analytics_overview", "Get analytics overview with total posts, impressions, engagements, engagement rate, and per-platform breakdown. Use `period` for a rolling window (7d / 30d / 90d) or `start_date` + `end_date` for a custom range. The response echoes the resolved range and today's date — read those before assuming a year from training data (e.g. when the user says 'April', use the most recent April, not April from your training cutoff).", {
69
+ period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
70
+ start_date: z.string().optional().describe('Custom start date. Accepts "YYYY-MM-DD" (e.g. "2026-04-01") or "YYYY-MM" month-shorthand (e.g. "2026-04" → first of the month).'),
71
+ end_date: z.string().optional().describe('Custom end date. Same formats as start_date; "YYYY-MM" expands to the last day of the month.'),
56
72
  }, async (params) => {
57
73
  const result = await getClient().getAnalyticsOverview(params);
58
74
  if (result.error) {
@@ -61,8 +77,15 @@ export function registerAnalyticsTools(server, getClient) {
61
77
  };
62
78
  }
63
79
  const d = result.data;
64
- const period = result.period || params.period || "30d";
65
- let md = `## Analytics Overview (Last ${period})\n\n`;
80
+ const resolvedStart = result.start_date;
81
+ const resolvedEnd = result.end_date;
82
+ const currentDate = result.current_date;
83
+ const periodLabel = resolvedStart && resolvedEnd
84
+ ? `${resolvedStart} → ${resolvedEnd}`
85
+ : result.period || params.period || "30d";
86
+ let md = `## Analytics Overview (${periodLabel})\n\n`;
87
+ if (currentDate)
88
+ md += `_Today: ${currentDate}_\n\n`;
66
89
  md += `**${d.total_posts} posts** across **${d.total_platforms} platforms** | `;
67
90
  md += `**${formatNumber(d.total_impressions)}** impressions | `;
68
91
  md += `**${formatNumber(d.total_engagement)}** engagements | `;
@@ -211,9 +211,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
211
211
  ]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Max 10 total. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media."),
212
212
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
213
213
  pinterest: z.object({
214
- board_id: z.string().optional(),
215
- title: z.string().optional(),
216
- link: z.string().optional(),
214
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
215
+ title: z.string().optional().describe("Pin title (max 100 characters)"),
216
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
217
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
218
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
217
219
  }).optional().describe("Pinterest-specific options"),
218
220
  youtube: z.object({
219
221
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
@@ -324,9 +326,11 @@ Do NOT call without required media — it will fail.`, {
324
326
  ]).optional().describe("External image/video URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
325
327
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
326
328
  pinterest: z.object({
327
- board_id: z.string().optional(),
328
- title: z.string().optional(),
329
- link: z.string().optional(),
329
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
330
+ title: z.string().optional().describe("Pin title (max 100 characters)"),
331
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
332
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
333
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
330
334
  }).optional().describe("Pinterest-specific options"),
331
335
  youtube: z.object({
332
336
  title: z.string().optional().describe("Short title shown on YouTube."),
@@ -378,9 +382,11 @@ Do NOT call without required media — it will fail.`, {
378
382
  });
379
383
  server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
380
384
 
385
+ **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
386
+
381
387
  **X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread.`, {
382
388
  id: z.string().describe("The post ID to update"),
383
- content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post content. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
389
+ content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post caption / body text. For YouTube Shorts this becomes the video description, NOT the title — to rename the Short, use `youtube.title`. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
384
390
  scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
385
391
  channels: z.array(z.string()).optional().describe("Updated channel IDs. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
386
392
  media_ids: z.union([
@@ -391,6 +397,36 @@ Do NOT call without required media — it will fail.`, {
391
397
  z.array(z.string()),
392
398
  z.record(z.string(), z.array(z.string())),
393
399
  ]).optional().describe("External URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
400
+ youtube: z.object({
401
+ title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
402
+ tags: z.array(z.string()).optional(),
403
+ privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
404
+ category_id: z.string().optional(),
405
+ made_for_kids: z.boolean().optional(),
406
+ notify_subscribers: z.boolean().optional(),
407
+ contains_synthetic_media: z.boolean().optional(),
408
+ }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
409
+ pinterest: z.object({
410
+ board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
411
+ title: z.string().optional().describe("Pin title (max 100 characters)"),
412
+ link: z.string().optional().describe("Destination URL the pin clicks through to"),
413
+ video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
414
+ alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
415
+ }).optional().describe("Pinterest-specific options"),
416
+ instagram: z.object({
417
+ share_to_feed: z.boolean().optional(),
418
+ thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
419
+ thumb_offset: z.number().optional(),
420
+ cover_url: z.string().optional(),
421
+ }).optional().describe("Instagram Reel options"),
422
+ tiktok: z.object({
423
+ privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
424
+ disable_comment: z.boolean().optional(),
425
+ disable_duet: z.boolean().optional(),
426
+ disable_stitch: z.boolean().optional(),
427
+ is_aigc: z.boolean().optional(),
428
+ brand_content_toggle: z.boolean().optional(),
429
+ }).optional().describe("TikTok options"),
394
430
  x: z.object({
395
431
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
396
432
  paid_partnership: z.boolean().optional(),
package/build/types.d.ts CHANGED
@@ -41,7 +41,15 @@ export interface Account {
41
41
  display_name: string;
42
42
  profile_picture: string | null;
43
43
  content_types: string[];
44
- status: string;
44
+ /**
45
+ * "active" while the OAuth token is healthy. Flips to "needs_reconnect"
46
+ * when the platform has revoked/expired the token — posts to this account
47
+ * will fail until the user reconnects.
48
+ */
49
+ status: "active" | "needs_reconnect" | string;
50
+ needs_reconnect: boolean;
51
+ /** Short reason from the platform; only present when needs_reconnect is true. */
52
+ reauth_reason?: string | null;
45
53
  connected_at: string | null;
46
54
  boards?: {
47
55
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.3.6",
3
+ "version": "1.3.8",
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",