@omnisocials/mcp-server 1.39.0 → 1.48.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.
@@ -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), 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.", {}, { title: "List Connected Accounts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, 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\"). Telegram accounts are a channel or group the OmniSocials bot posts to; `platform_details` carries `chat_type` (channel, supergroup or group), `subscribers` and `url`. Discord accounts are one Discord channel the workspace posts to through its webhook (posts show the webhook's name and picture); `platform_details` carries `guild_id`, `guild_name` (the server, when connected with Continue with Discord), `channel_id`, `channel_name` (read by the OmniSocials bot or given by the person, else null), `channel_type` (text, voice, announcement, stage, forum or media; a forum or media channel needs `discord.thread_name` on every post), `webhook_name`, `connect_method` (oauth or url) and `url`. 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.", {}, { title: "List Connected Accounts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
5
5
  const result = await getClient().listAccounts();
6
6
  if (result.error) {
7
7
  return {
@@ -20,13 +20,21 @@ export function registerAccountTools(server, getClient) {
20
20
  md += `|---|----------|---------|---------------|------------|--------|\n`;
21
21
  for (let i = 0; i < accounts.length; i++) {
22
22
  const a = accounts[i];
23
- const name = a.username ? `@${a.username}` : a.display_name || "—";
23
+ // Discord: the server, then the channel the bot read or the person gave.
24
+ const channel = a.platform === "discord" && a.platform_details?.channel_name ? ` · #${a.platform_details.channel_name}` : "";
25
+ const name = `${a.username ? `@${a.username}` : a.display_name || "—"}${channel}`;
24
26
  const types = (a.content_types || []).join(", ");
25
27
  const platform = capitalize(a.platform);
26
28
  const subType = a.platform_details?.subscription_type;
29
+ const chatType = a.platform === "telegram" ? a.platform_details?.chat_type : null;
30
+ const guildName = a.platform === "discord" ? a.platform_details?.guild_name : null;
27
31
  const sub = subType === "Premium" || subType === "PremiumPlus"
28
32
  ? ` (${subType})`
29
- : "";
33
+ : chatType
34
+ ? ` (${chatType === "channel" ? "channel" : "group"})`
35
+ : guildName && guildName !== a.display_name
36
+ ? ` (${guildName})`
37
+ : "";
30
38
  const statusCell = a.needs_reconnect
31
39
  ? `⚠️ needs_reconnect`
32
40
  : "active";
@@ -93,6 +101,30 @@ export function registerAccountTools(server, getClient) {
93
101
  if (getSubType === "Premium" || getSubType === "PremiumPlus") {
94
102
  md += `| **Subscription** | ${getSubType} |\n`;
95
103
  }
104
+ if (a.platform === "telegram" && a.platform_details) {
105
+ const d = a.platform_details;
106
+ if (d.chat_type)
107
+ md += `| **Chat type** | ${d.chat_type} |\n`;
108
+ if (typeof d.subscribers === "number")
109
+ md += `| **Subscribers** | ${d.subscribers.toLocaleString("en-US")} |\n`;
110
+ if (d.url)
111
+ md += `| **Link** | ${d.url} |\n`;
112
+ }
113
+ if (a.platform === "discord" && a.platform_details) {
114
+ const d = a.platform_details;
115
+ if (d.guild_name)
116
+ md += `| **Server** | ${d.guild_name} |\n`;
117
+ if (d.channel_name)
118
+ md += `| **Channel** | #${d.channel_name}${d.channel_type ? ` (${d.channel_type})` : ""} |\n`;
119
+ if (d.webhook_name)
120
+ md += `| **Posts as** | ${d.webhook_name} (the webhook's name) |\n`;
121
+ if (d.channel_id)
122
+ md += `| **Channel ID** | ${d.channel_id} |\n`;
123
+ if (d.connect_method)
124
+ md += `| **Connected with** | ${d.connect_method === "oauth" ? "Continue with Discord" : "a webhook URL"} |\n`;
125
+ if (d.url)
126
+ md += `| **Link** | ${d.url} |\n`;
127
+ }
96
128
  if (a.boards?.length) {
97
129
  md += `\n### Pinterest Boards\n\n`;
98
130
  md += `| Board Name | Board ID |\n`;
@@ -1,8 +1,8 @@
1
1
  import { z } from "zod";
2
- import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels } from "../client.js";
2
+ import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels, NON_ADDITIVE_METRIC_KEYS } from "../client.js";
3
3
  import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
4
4
  export function registerAnalyticsTools(server, getClient) {
5
- server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
5
+ server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon, Telegram, Discord and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization). Video retention: hold_rate_3s (% of viewers still watching at second 3) on TikTok videos (with TikTok comments enabled), Facebook Reels, and Instagram Reels (there 100 minus skip_rate, Meta's 3-second skip rate); a 'Still watching' row sums up the retention curve (TikTok videos, Facebook Reels and videos) at 1, 3, 5, 10, 15 and 30 seconds and at each quarter of the video. Instagram and YouTube give no curve through their APIs. Rates and averages show per platform, never added up across platforms. Telegram gives bots no view counts: a Telegram post carries likes (all reactions), reactions (per emoji) and, for a poll, the votes. Discord reports the same for a webhook message: likes (all reactions), reactions (per emoji) and the poll votes, no views.", {
6
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."),
7
7
  }, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
8
8
  const result = await getClient().getPostAnalytics(post_id);
@@ -47,7 +47,11 @@ export function registerAnalyticsTools(server, getClient) {
47
47
  const denom = getImpressions(platform, m);
48
48
  totalEngagements += engagements;
49
49
  totalDenom += denom;
50
- for (const [label, n] of rows) {
50
+ for (const [label, n, key] of rows) {
51
+ // Rates, averages and lengths do not add up across platforms (two
52
+ // 60% hold rates are not 120%); they show per platform only.
53
+ if (NON_ADDITIVE_METRIC_KEYS.has(key))
54
+ continue;
51
55
  if (!totalsByLabel.has(label))
52
56
  totalOrder.push(label);
53
57
  totalsByLabel.set(label, (totalsByLabel.get(label) || 0) + n);
@@ -310,7 +314,7 @@ export function registerAnalyticsTools(server, getClient) {
310
314
  };
311
315
  });
312
316
  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, and blended with when the account's followers are online when the platform provides that (Instagram, TikTok Business; `basis: own_data_and_audience`, response carries `audience_online`). Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, the audience-online profile alone is used (`basis: audience`) or industry-average defaults are returned (`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.", {
313
- platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business"),
317
+ platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business, telegram, discord"),
314
318
  timezone: z.string().optional().describe("IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone."),
315
319
  }, { title: "Get Best Times to Post", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
316
320
  const result = await getClient().getBestTimes(params);
@@ -0,0 +1,25 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { OmniSocialsClient } from "../client.js";
3
+ import type { Automation, AutomationInput, AutomationPlatform } from "../types.js";
4
+ export declare const automationChannelLabel: (platform: string | null | undefined) => "Facebook Page" | "Instagram" | "X";
5
+ type Fields = {
6
+ platform?: AutomationPlatform;
7
+ name?: string;
8
+ keywords?: string[];
9
+ match?: "word" | "contains" | "any";
10
+ dm_text?: string;
11
+ public_replies?: string[];
12
+ triggers?: Array<"comment" | "dm" | "story_reply">;
13
+ scope?: "post" | "next_post" | "any";
14
+ post_id?: string;
15
+ instagram_media_id?: string;
16
+ facebook_post_id?: string;
17
+ max_sends?: number | null;
18
+ stop_at?: string | null;
19
+ };
20
+ export declare function buildAutomationBody(f: Fields, { create }: {
21
+ create: boolean;
22
+ }): AutomationInput;
23
+ export declare function describeAutomation(a: Automation): string;
24
+ export declare function registerAutomationTools(server: McpServer, getClient: () => OmniSocialsClient): void;
25
+ export {};
@@ -0,0 +1,216 @@
1
+ import { z } from "zod";
2
+ import { formatDateTime, truncate } from "../client.js";
3
+ const SCOPE_HINT = "The API key needs the opt-in Automations scopes (OmniSocials Settings > API > create a key with Automations on: automations:read and automations:write).";
4
+ const errorText = (error) => `Error (${error.code}): ${error.message}${error.code === "insufficient_scope" ? `\n\n${SCOPE_HINT}` : ""}`;
5
+ const PLATFORM = z.enum(["instagram", "facebook", "x"]);
6
+ const KINDS = z.enum(["comment", "dm", "story_reply"]);
7
+ const OUTCOME = z.enum(["sent", "skipped", "failed", "pending", "queued", "opted_out"]);
8
+ // How the channel reads in a table or a sentence.
9
+ export const automationChannelLabel = (platform) => platform === "facebook" ? "Facebook Page" : platform === "x" ? "X" : "Instagram";
10
+ const MATCH = z.enum(["word", "contains", "any"]);
11
+ const SCOPE = z.enum(["post", "next_post", "any"]);
12
+ // The shared fields of create_automation and update_automation, flat so an
13
+ // assistant fills them from one sentence ("DM everyone who comments guide").
14
+ const AUTOMATION_FIELDS = {
15
+ platform: PLATFORM.optional().describe("Where the automation runs: 'instagram' (default) = the connected Instagram account, 'facebook' = the connected Facebook Page (the DM lands in Messenger), 'x' = the connected X account (it answers DMs only; see create_automation). instagram_media_id implies 'instagram', facebook_post_id implies 'facebook'. It cannot change after create."),
16
+ name: z.string().max(120).optional().describe("A label for the automations list, e.g. 'Free guide'."),
17
+ keywords: z.array(z.string()).max(20).optional().describe("Keywords that start the automation (up to 20, matched case-insensitively), e.g. [\"guide\", \"link\"]. Required unless match is 'any'."),
18
+ match: MATCH.optional().describe("word (default): the keyword stands on its own ('guide', 'Guide!' match; 'linkedin' does not match 'link'). contains: the keyword appears anywhere. any: every comment or message with text (ask the user before using it)."),
19
+ dm_text: z.string().max(1000).optional().describe("The DM sent to the person (max 1000 characters), once per person. On Facebook it lands in Messenger. On X, OmniSocials adds the line 'Reply STOP to stop automatic messages.' under it. Required on create. Confirm the text with the user before creating the automation: it goes out in the account's name."),
20
+ public_replies: z.array(z.string().max(300)).max(10).optional().describe("Optional public replies posted under the comment (one is picked at random per comment), e.g. [\"Sent you a DM!\", \"Check your inbox\"]. Comment trigger only (Instagram and Facebook; X has none). An empty array turns the public reply off."),
21
+ triggers: z.array(KINDS).min(1).optional().describe("What starts the automation. 'comment' (default on Instagram and Facebook) = a comment on an Instagram post or reel, or on a Facebook Page post; 'dm' = a keyword in a DM to the Instagram account, in a Messenger message to the Facebook Page, or in a DM to the X account; 'story_reply' = a keyword in a reply to an Instagram story (Instagram only: Facebook has no story replies). On X only 'dm' exists, and it is the default there. One automation can listen to several."),
22
+ scope: SCOPE.optional().describe("For the comment trigger (Instagram and Facebook; an X automation has no post): 'post' (default) = one post (pass post_id, instagram_media_id or facebook_post_id), 'next_post' = the next post the workspace publishes on this platform, 'any' = every post of the account or Page."),
23
+ post_id: z.string().optional().describe("For scope 'post' (Instagram and Facebook): the OmniSocials post id (from list_posts) that goes to the automation's platform. A scheduled post starts listening once it is live. One automation per post and platform: a post that goes to Instagram and Facebook can have one of each."),
24
+ instagram_media_id: z.string().optional().describe("For scope 'post' on Instagram: the numeric Instagram media id of a post or reel that was NOT published through OmniSocials. OmniSocials reads it from Instagram to check it belongs to the connected account. Pass only one of post_id, instagram_media_id or facebook_post_id."),
25
+ facebook_post_id: z.string().optional().describe("For scope 'post' on Facebook: the id of a Facebook Page post that was NOT published through OmniSocials ({page-id}_{post-id}, or the post id alone). OmniSocials reads it from Facebook to check it belongs to the connected Page. Implies platform 'facebook'. Pass only one of post_id, instagram_media_id or facebook_post_id."),
26
+ max_sends: z.number().int().positive().nullable().optional().describe("Stop after this many DMs (the automation becomes 'completed'). null = no limit."),
27
+ stop_at: z.string().nullable().optional().describe("Stop at this time (ISO 8601). null = no end."),
28
+ };
29
+ // Turn the flat tool fields into the API body. Only the sections that were
30
+ // given are sent, so update_automation leaves the rest untouched. The API's
31
+ // default trigger is a comment, which X does not have: an X create without
32
+ // triggers sends ["dm"].
33
+ export function buildAutomationBody(f, { create }) {
34
+ const body = {};
35
+ if (f.name !== undefined)
36
+ body.name = f.name;
37
+ if (f.post_id !== undefined)
38
+ body.post_id = f.post_id;
39
+ if (f.platform !== undefined)
40
+ body.platform = f.platform;
41
+ if (f.instagram_media_id !== undefined)
42
+ body.instagram_media_id = f.instagram_media_id;
43
+ if (f.facebook_post_id !== undefined)
44
+ body.facebook_post_id = f.facebook_post_id;
45
+ if (create || f.keywords !== undefined || f.match !== undefined || f.triggers !== undefined || f.scope !== undefined) {
46
+ body.trigger = {};
47
+ if (f.triggers !== undefined)
48
+ body.trigger.kinds = f.triggers;
49
+ else if (create && f.platform === "x")
50
+ body.trigger.kinds = ["dm"];
51
+ if (f.scope !== undefined)
52
+ body.trigger.scope = f.scope;
53
+ if (f.keywords !== undefined)
54
+ body.trigger.keywords = f.keywords;
55
+ if (f.match !== undefined)
56
+ body.trigger.match = f.match;
57
+ }
58
+ if (create || f.dm_text !== undefined || f.public_replies !== undefined) {
59
+ body.actions = {
60
+ dm: { text: f.dm_text ?? "" },
61
+ ...(f.public_replies !== undefined
62
+ ? { public_reply: { enabled: f.public_replies.length > 0, variations: f.public_replies } }
63
+ : {}),
64
+ };
65
+ }
66
+ if (f.max_sends !== undefined || f.stop_at !== undefined) {
67
+ body.limits = {};
68
+ if (f.max_sends !== undefined)
69
+ body.limits.max_sends = f.max_sends;
70
+ if (f.stop_at !== undefined)
71
+ body.limits.stop_at = f.stop_at;
72
+ }
73
+ return body;
74
+ }
75
+ export function describeAutomation(a) {
76
+ const kinds = a.trigger.kinds.join(", ");
77
+ const scope = a.trigger.scope ? ` on ${a.trigger.scope === "post" ? "one post" : a.trigger.scope === "next_post" ? "the next post" : "every post"}` : "";
78
+ const keywords = a.trigger.match === "any" ? "any text" : a.trigger.keywords.map((k) => `"${k}"`).join(", ");
79
+ let md = `## Automation \`${a.id}\`${a.name ? ` "${a.name}"` : ""}\n\n`;
80
+ md += `| Field | Value |\n|-------|-------|\n`;
81
+ md += `| **Platform** | ${automationChannelLabel(a.platform)} |\n`;
82
+ md += `| **Status** | ${a.status} |\n`;
83
+ md += `| **Trigger** | ${kinds}${scope}, keywords ${keywords} (${a.trigger.match}) |\n`;
84
+ if (a.post) {
85
+ const label = a.post.caption ? truncate(a.post.caption, 60) : a.post.permalink || a.post.id;
86
+ md += `| **Post** | ${label}${a.post.permalink ? ` (${a.post.permalink})` : ""}${a.post.live === false ? " (not live yet)" : ""} |\n`;
87
+ }
88
+ else if (a.post_id) {
89
+ md += `| **Post** | OmniSocials post \`${a.post_id}\` |\n`;
90
+ }
91
+ md += `| **DM** | ${truncate(a.actions.dm.text, 120)} |\n`;
92
+ if (a.actions.public_reply.enabled && a.actions.public_reply.variations.length) {
93
+ md += `| **Public reply** | ${a.actions.public_reply.variations.map((v) => `"${truncate(v, 40)}"`).join(", ")} |\n`;
94
+ }
95
+ const limits = [a.limits.max_sends ? `max ${a.limits.max_sends} DMs` : null, a.limits.stop_at ? `until ${formatDateTime(a.limits.stop_at)}` : null].filter(Boolean);
96
+ if (limits.length)
97
+ md += `| **Limits** | ${limits.join(", ")} |\n`;
98
+ md += `| **Sent** | ${a.stats.sent} DMs (${a.stats.matched} matched, ${a.stats.skipped} skipped, ${a.stats.failed} failed) |\n`;
99
+ return md;
100
+ }
101
+ export function registerAutomationTools(server, getClient) {
102
+ server.tool("list_automations", "List the comment-to-DM automations of this workspace, on Instagram, on the Facebook Page and on X. An automation DMs a person once when they comment a keyword on a post (or send it in a DM, or in an Instagram story reply), with an optional public reply under the comment; on X it answers DMs only. Use it to answer 'which automations run', before creating one on a post that may have one already, or to find the id for update_automation / get_automation_activity.", {
103
+ status: z.enum(["active", "paused", "completed"]).optional().describe("Only automations with this status."),
104
+ post_id: z.string().optional().describe("Only the automations bound to this OmniSocials post (one per platform at most)."),
105
+ platform: PLATFORM.optional().describe("Only the automations of this platform."),
106
+ }, { title: "List Automations", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
107
+ const result = await getClient().listAutomations(params);
108
+ if (result.error)
109
+ return { content: [{ type: "text", text: errorText(result.error) }] };
110
+ const items = Array.isArray(result.data) ? result.data : [];
111
+ if (!items.length) {
112
+ return { content: [{ type: "text", text: "No comment-to-DM automations yet. Create one with create_automation, or pass comment_dm_automation on create_post." }] };
113
+ }
114
+ let md = `## Automations (${items.length})\n\n`;
115
+ md += `| ID | Platform | Status | Trigger | Keywords | Post | Sent |\n|----|----------|--------|---------|----------|------|------|\n`;
116
+ for (const a of items) {
117
+ const post = a.post ? truncate(a.post.caption || a.post.permalink || a.post.id, 40) : a.post_id ? `post ${a.post_id}` : a.trigger.scope === "next_post" ? "next post" : a.trigger.scope === "any" ? "every post" : "";
118
+ const keywords = a.trigger.match === "any" ? "any text" : a.trigger.keywords.join(", ");
119
+ md += `| \`${a.id}\`${a.name ? ` ${a.name}` : ""} | ${automationChannelLabel(a.platform)} | ${a.status} | ${a.trigger.kinds.join(", ")} | ${keywords} | ${post} | ${a.stats.sent} |\n`;
120
+ }
121
+ md += `\nDetails with get_automation, activity with get_automation_activity, pause or edit with update_automation.`;
122
+ return { content: [{ type: "text", text: md }] };
123
+ });
124
+ server.tool("get_automation", "Get one comment-to-DM automation: its platform (Instagram, the Facebook Page or X), its trigger, the DM text, the public replies, the limits and the counts.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Get Automation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id }) => {
125
+ const result = await getClient().getAutomation(automation_id);
126
+ if (result.error)
127
+ return { content: [{ type: "text", text: errorText(result.error) }] };
128
+ if (!result.data)
129
+ return { content: [{ type: "text", text: "The API returned no automation." }] };
130
+ return { content: [{ type: "text", text: describeAutomation(result.data) }] };
131
+ });
132
+ server.tool("create_automation", "Create a comment-to-DM automation on the connected Instagram account (default), on the connected Facebook Page (platform 'facebook') or on the connected X account (platform 'x'): when somebody comments one of the keywords on the post (or sends it in a DM, or in an Instagram story reply, see triggers), the account DMs them dm_text once, and optionally posts one of public_replies under the comment. On Facebook the DM lands in Messenger. Before creating one, show the user the keyword(s) and the exact DM text and get a yes: the DM goes out in their name. Meta rules, the same on Instagram and Facebook: one private reply per comment, within 7 days of the comment; a follow-up only after the person answers (24-hour window); OmniSocials caps sends at 180 DMs an hour per account and platform, and an answer over the cap waits in a queue and goes out within the hour. For scope 'post' pass one of post_id (an OmniSocials post, scheduled or live), instagram_media_id (an Instagram post published outside OmniSocials) or facebook_post_id (a Page post published outside OmniSocials). On X the automation answers DMs only: triggers ['dm'] (the default there; X allows an automated DM only after the person wrote first), so a DM to the X account with a keyword, or any first message with match 'any', gets dm_text once per person; no post fields and no public replies. An X automation runs only while X DMs are turned on for the workspace in OmniSocials (Social Inbox, X DMs card, or the automation editor): an active create without them answers 409 x_dms_off, so tell the user where to turn them on, or create it with status 'paused'. 409 x_not_connected means the workspace has no X account. Every X answer ends with the line 'Reply STOP to stop automatic messages.', and a person who replies STOP gets no automatic DM from that X account again (the stop list belongs to the account: it holds after the automation is deleted, for later automations and in every workspace with that account). X takes at most 15 answers per 15 minutes and 450 a day per account; more wait in the queue for up to an hour. Each X answer uses X DM credits like an inbox reply (2 credits); when the credits ran out the answer is skipped (no_credits): top up, then answer in the Social Inbox. X does not give apps the messages of an encrypted X Chat conversation, so those get no answer. To attach automations to a NEW post, pass comment_dm_automation on create_post instead (Instagram and Facebook).", {
133
+ ...AUTOMATION_FIELDS,
134
+ status: z.enum(["active", "paused"]).optional().describe("'paused' creates it without turning it on (default 'active'). A paused X automation saves even while X DMs are off; turn it on later with update_automation."),
135
+ }, { title: "Create Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ status, ...params }) => {
136
+ if (!params.dm_text)
137
+ return { content: [{ type: "text", text: "dm_text is required: the DM the person receives." }] };
138
+ const body = buildAutomationBody(params, { create: true });
139
+ if (status)
140
+ body.status = status;
141
+ const result = await getClient().createAutomation(body);
142
+ if (result.error)
143
+ return { content: [{ type: "text", text: errorText(result.error) }] };
144
+ if (!result.data)
145
+ return { content: [{ type: "text", text: "Automation created, but the API returned no details. List it with list_automations." }] };
146
+ const a = result.data;
147
+ let md = `Created automation \`${a.id}\` (${a.status}).\n\n${describeAutomation(a)}`;
148
+ if (a.post && a.post.live === false)
149
+ md += `\n_The post is not live on ${a.platform === "facebook" ? "the Facebook Page" : "Instagram"} yet; the automation starts listening at publish._`;
150
+ if (a.platform === "x" && a.status === "paused")
151
+ md += `\n_Paused: it answers nothing until it is turned on (update_automation with status 'active'), which needs X DMs on for the workspace._`;
152
+ return { content: [{ type: "text", text: md }] };
153
+ });
154
+ server.tool("update_automation", "Change a comment-to-DM automation: pause or resume it (status), new keywords or match, a new DM text or public replies, new limits, or move it to another post (post_id, instagram_media_id or facebook_post_id). Only the fields you pass change. The platform cannot change: to move to another channel (Instagram, the Facebook Page or X), create a new automation. Turning an X automation on (status 'active') needs X DMs on for the workspace, else 409 x_dms_off. A 'completed' automation starts again when its limits or trigger change.", {
155
+ automation_id: z.string().describe("The automation id (from list_automations)."),
156
+ status: z.enum(["active", "paused"]).optional().describe("'paused' stops it without deleting it; 'active' resumes it (on X only while X DMs are on for the workspace)."),
157
+ ...AUTOMATION_FIELDS,
158
+ }, { title: "Update Automation", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ automation_id, status, ...fields }) => {
159
+ const body = buildAutomationBody(fields, { create: false });
160
+ if (status)
161
+ body.status = status;
162
+ if (Object.keys(body).length === 0)
163
+ return { content: [{ type: "text", text: "Nothing to change: pass status, keywords, dm_text, public_replies, limits or a post." }] };
164
+ const result = await getClient().updateAutomation(automation_id, body);
165
+ if (result.error)
166
+ return { content: [{ type: "text", text: errorText(result.error) }] };
167
+ if (!result.data)
168
+ return { content: [{ type: "text", text: "Automation updated, but the API returned no details. Read it with get_automation." }] };
169
+ return { content: [{ type: "text", text: `Updated automation \`${automation_id}\`.\n\n${describeAutomation(result.data)}` }] };
170
+ });
171
+ server.tool("delete_automation", "Delete a comment-to-DM automation (Instagram, Facebook Page or X) and its activity log. DMs that were already sent stay sent. To stop it for a while, use update_automation with status 'paused' instead. Confirm with the user first.", { automation_id: z.string().describe("The automation id (from list_automations).") }, { title: "Delete Automation", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ automation_id }) => {
172
+ const result = await getClient().deleteAutomation(automation_id);
173
+ if (result.error)
174
+ return { content: [{ type: "text", text: errorText(result.error) }] };
175
+ return { content: [{ type: "text", text: `Deleted automation \`${automation_id}\`.` }] };
176
+ });
177
+ server.tool("get_automation_activity", "The activity of a comment-to-DM automation, newest first: one row per comment or message it acted on, with the DM sent, an answer that waits in the queue (queued: the send cap is full or X asked to wait; it goes out within the hour), a skip and its reason (already got the DM, limit reached, comment older than 7 days, the send cap stayed full for an hour, the person does not accept DMs from the X account, credits ran out before the answer, the person replied STOP earlier, the automation was paused while the answer waited), a person who replied STOP on X (opted_out: no automatic DMs to them from that X account again, in any workspace or automation), or a failure with the platform's error. On the Facebook Page the person shows by name (Facebook has no @handle). Use it to answer 'how many people got the DM' or 'why did this person not get it'.", {
178
+ automation_id: z.string().describe("The automation id (from list_automations)."),
179
+ outcome: OUTCOME.optional().describe("Only rows with this outcome."),
180
+ limit: z.number().int().min(1).max(100).optional().describe("Rows per page (default 25, max 100)."),
181
+ offset: z.number().int().min(0).optional().describe("Rows to skip (for the next page)."),
182
+ }, { title: "Get Automation Activity", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ automation_id, ...params }) => {
183
+ // The automation's platform decides how a person shows: an Instagram
184
+ // @handle, or a Facebook name.
185
+ const [result, automation] = await Promise.all([
186
+ getClient().listAutomationEvents(automation_id, params),
187
+ getClient().getAutomation(automation_id).catch(() => null),
188
+ ]);
189
+ if (result.error)
190
+ return { content: [{ type: "text", text: errorText(result.error) }] };
191
+ const onFacebook = automation?.data?.platform === "facebook";
192
+ const rows = Array.isArray(result.data) ? result.data : [];
193
+ const counts = result.counts || {};
194
+ const countLine = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing yet";
195
+ if (!rows.length) {
196
+ return { content: [{ type: "text", text: `## Activity of automation \`${automation_id}\`\n\nTotals: ${countLine}.\n\nNo rows${params.outcome ? ` with outcome ${params.outcome}` : ""}.` }] };
197
+ }
198
+ let md = `## Activity of automation \`${automation_id}\`\n\nTotals: ${countLine}.\n\n`;
199
+ md += `| When | Who | Did | Outcome | Text |\n|------|-----|-----|---------|------|\n`;
200
+ for (const e of rows) {
201
+ const who = e.person.username ? (onFacebook ? e.person.username : `@${e.person.username}`) : e.person.id;
202
+ const did = e.kind === "comment" ? "commented" : e.kind === "dm" ? (onFacebook ? "sent a Messenger message" : "sent a DM") : "replied to a story";
203
+ const outcome = e.outcome === "skipped" ? `skipped: ${e.reason_label || e.reason || ""}`
204
+ : e.outcome === "failed" ? `failed: ${truncate(e.reason || "", 60)}`
205
+ : e.outcome === "queued" ? `queued: ${e.reason_label || "waits for room under the send limit"}`
206
+ : e.outcome === "opted_out" ? `opted out: ${e.reason_label || "replied STOP"}`
207
+ : e.outcome;
208
+ const text = e.trigger_text ? `"${truncate(e.trigger_text, 50)}"` : "";
209
+ md += `| ${formatDateTime(e.sent_at || e.created_at)} | ${who} | ${did} | ${outcome} | ${text} |\n`;
210
+ }
211
+ const p = result.pagination;
212
+ if (p?.has_more)
213
+ md += `\n_${p.total} rows in total; pass offset ${p.offset + p.limit} for the next page._`;
214
+ return { content: [{ type: "text", text: md }] };
215
+ });
216
+ }
@@ -188,7 +188,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
188
188
  });
189
189
  server.tool("check_media_compatibility", `Check whether a video/image will be accepted by the platforms CONNECTED to this workspace, BEFORE uploading or posting. Use this when the user gives you a file (URL) so you can warn them upfront — e.g. "this 995 MB video won't post to Instagram (max 300 MB), continue?" — and confirm before uploading.
190
190
 
191
- Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an existing 'media_id', or explicit 'size_bytes' + 'mime'. Returns the connected platforms and any that would reject the file by size or format.`, {
191
+ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an existing 'media_id', or explicit 'size_bytes' + 'mime'. Returns the connected platforms and any that would reject the file by size or format, plus IMAGE FIT rows for an image whose size is known (a library file): per connected channel, whether its feed shows the image uncropped and what a post does by default (Instagram crops a 9:16 / wide image to 4:5 / 1.91:1 around the subject; Pinterest keeps a tall Pin and reports it). When a fit row says fits: false, ask the user "crop to <target> (recommended for Instagram: Meta can boost it) or keep the original (the channel cuts it itself)?" and pass the answer as media_fit on create_post.`, {
192
192
  url: z.string().optional().describe("Public URL of the file to check. Use this OR media_id OR size_bytes+mime."),
193
193
  media_id: z.string().optional().describe("Id of an already-uploaded library item to check."),
194
194
  size_bytes: z.number().optional().describe("File size in bytes (use with mime when you already know them)."),
@@ -225,6 +225,19 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
225
225
  }
226
226
  md += `\nAsk the user whether to continue. It will still post to the platforms not listed above.`;
227
227
  }
228
+ const outside = (r.fit || []).filter((f) => !f.fits);
229
+ if (outside.length) {
230
+ const dims = r.file?.width && r.file?.height ? ` (${r.file.width}x${r.file.height})` : "";
231
+ md += `\n\n**Image fit**${dims}: how these channels show it in the feed\n\n| Platform | Ratio | In the feed | A post does |\n|----------|-------|-------------|-------------|\n`;
232
+ for (const f of outside) {
233
+ const does = f.default_action === "crop" ? `crops it to ${f.target} (media_fit "keep" sends the original)` : `keeps it and reports it (media_fit "crop" crops to ${f.target})`;
234
+ md += `| ${f.display_name} | ${f.ratio} | cut to ${f.target} | ${does} |\n`;
235
+ }
236
+ md += `\nAsk the user per channel above: crop to the target (Instagram: Meta can then boost the post) or keep the original (the channel cuts it itself)? Pass the answer as \`media_fit\` on create_post. Stories, reels and the other channels are not affected.`;
237
+ }
238
+ else if ((r.fit || []).length) {
239
+ md += `\n\nImage fit: this image shows uncropped in every connected feed with a rule (${(r.fit || []).map((f) => f.display_name).join(", ")}).`;
240
+ }
228
241
  return { content: [{ type: "text", text: md }] };
229
242
  });
230
243
  server.tool("update_media", "Rename an existing media file or move it into a folder (so it's findable later instead of re-uploading it). Only the fields you pass are changed.", {