@omnisocials/mcp-server 1.32.0 → 1.34.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/README.md CHANGED
@@ -115,7 +115,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
115
115
 
116
116
  ## Available Tools
117
117
 
118
- ### Posts (12 tools)
118
+ ### Posts (13 tools)
119
119
 
120
120
  | Tool | Description |
121
121
  |------|-------------|
@@ -127,6 +127,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
127
127
  | `delete_post` | Delete a post |
128
128
  | `publish_post` | Publish a draft or scheduled post now |
129
129
  | `retry_post` | Retry the failed platforms of a failed or partially failed post |
130
+ | `list_approval_workflows` | List the workspace's approval workflows (steps + approvers); pass an id as `approval_workflow_id` on `create_post` to hold the post for review |
130
131
  | `approve_post` | Approve the current step of a post's approval workflow (the connected user must be a listed approver for that step) |
131
132
  | `reject_post` | Reject a post's approval workflow, stopping it immediately |
132
133
  | `search_locations` | Find an Instagram or Threads location to tag (Instagram place IDs for `location_id`, Threads location ids for `threads.location_id`; Threads also searches by latitude/longitude) |
@@ -188,15 +189,18 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
188
189
  | `delete_webhook` | Delete a webhook |
189
190
  | `rotate_webhook_secret` | Rotate a webhook's signing secret |
190
191
 
191
- ### Social Inbox (5 tools)
192
+ ### Social Inbox (8 tools)
192
193
 
193
194
  | Tool | Description |
194
195
  |------|-------------|
195
- | `list_inbox_conversations` | List conversations — Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs (opt-in), and Threads replies on your posts plus mentions (rolling out); filter by platform, type, or unread |
196
- | `get_inbox_conversation` | Get a conversation's full message history |
197
- | `mark_inbox_read` | Mark all incoming messages in a conversation as read |
198
- | `reply_to_inbox` | Reply to a DM, comment, or mention (existing conversations only) |
199
- | `hide_inbox_reply` | Hide or unhide a reply on one of your Threads posts (Threads only for now) |
196
+ | `list_inbox_conversations` | List conversations: Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs (opt-in), and Threads replies on your posts plus mentions; filter by platform, type, unread, or `unanswered` |
197
+ | `get_next_unanswered` | The next item that still needs an answer (longest-waiting first) with the whole thread and the post it is on; filter by platform or type, `include_read`, `exclude` |
198
+ | `get_inbox_conversation` | Get a conversation's full message history with the post it belongs to; every message shows its `#id` |
199
+ | `mark_inbox_read` | Mark all incoming messages in a conversation as read (also the durable skip for `get_next_unanswered`) |
200
+ | `reply_to_inbox` | Reply to a DM, comment, or mention (existing conversations only); `include_next` returns the next unanswered item in the same call |
201
+ | `hide_inbox_comment` | Hide or unhide a comment on one of your posts (Facebook, Instagram, TikTok, YouTube, Threads) |
202
+ | `delete_inbox_comment` | Permanently delete a comment on one of your posts, with the replies under it (Facebook, Instagram, TikTok) |
203
+ | `hide_inbox_reply` | Older name for `hide_inbox_comment`, kept for compatibility |
200
204
 
201
205
  Requires an API key with the opt-in `inbox:read` / `inbox:write` scopes — enable "Social Inbox access" when you create the key.
202
206
 
@@ -223,6 +227,18 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
223
227
 
224
228
  ## Changelog
225
229
 
230
+ ### 1.34.0 (2026-09-06)
231
+
232
+ - **Added: work through the inbox one item at a time.** New tool `get_next_unanswered` returns the next inbox item that still needs an answer, with the whole conversation and the post it is on, in one call: the customer's latest DM with no reply after it (Instagram/Facebook DMs within the 24-hour reply window), or a comment/mention nobody replied to and that is not hidden. Longest-waiting first; filter with `platform` and `type`; only unread items by default (`mark_inbox_read` is the durable skip, `include_read: true` widens it, `exclude` skips up to 100 conversation ids for one call); looks back 30 days and reports `remaining`. `reply_to_inbox` accepts `include_next: true` and returns the next item in the same response. `list_inbox_conversations` gains an `unanswered` filter. Replies typed in the native apps now appear as outgoing messages and count as answers. Same on the companion server (`mcp.omnisocials.com`).
233
+ - **Added: hide and delete comments on every comment platform.** New tool `hide_inbox_comment` hides (or unhides with `hide: false`) a comment on one of your posts on Facebook, Instagram, TikTok, YouTube and Threads (on YouTube, hide sets the comment to `rejected` and unhide publishes it again); `hide_inbox_reply` stays as an alias. New tool `delete_inbox_comment` permanently deletes a comment on Facebook, Instagram and TikTok, on the platform and from the inbox, with the replies under it (YouTube cannot delete other people's comments: hide instead). Both take the message id (`#id` in the thread). Errors: `403 reconnect_required`, `429 quota_exceeded` (YouTube hide), `502 platform_error`, `404 account_not_connected`, `401 reauth_required` (TikTok/Threads). `hidden` is now a boolean for every comment platform, `null` for DMs. Same on the companion server.
234
+ - **Added: post context in inbox threads.** The `post` object on conversations and messages carries `url` and `media_type` next to `id`, `caption` and `thumbnail`. `get_inbox_conversation` and `get_next_unanswered` render the post above the thread, and every message shows its `#id` plus `hidden` and reply-to-a-comment markers. Same on the companion server.
235
+
236
+ ### 1.33.0 (2026-09-06)
237
+
238
+ - **Added: submit posts for approval.** New tool `list_approval_workflows` lists the workspace's approval workflows (id, steps, named approvers; workflows are created in the dashboard under Approvals). `create_post` accepts `approval_workflow_id`: the post is created as `in_approval` instead of `scheduled`, the approvers are notified (dashboard badge, email, Slack), and it publishes at `scheduled_at` once the workflow's last step approves it (Approvals page, or `approve_post`). This is the flow for "the agent prepares everything, a human only approves". Requires `scheduled_at`; not allowed with `publish_now`. Errors: `404 workflow_not_found`, `400 validation_error` (no `scheduled_at`, or no approvers on the first step). Same on the companion server (`mcp.omnisocials.com`).
239
+ - **Added: platform options in `get_post`.** A new "Platform Options" section renders the TikTok options that were set (title, privacy level, cover frame `video_cover_timestamp_ms`, comment/duet/stitch toggles, AI-generated and brand disclosures, auto-add music), the YouTube options (title, privacy, tags, made for kids) and the Pinterest options (board, title, link). Previously only `first_comment` was rendered, so a pinned TikTok cover could not be verified without opening the post. Same on the companion server.
240
+ - **Changed: inbox attachment types.** Inbox `attachment.type` now reflects the fetched bytes rather than the platform's label (a generic photo DM is `image`, an MP4 story mention is `video`; `file` only when we could not classify the media), and a new `link` type carries the platform's own URL when it did not point at media (for example a shared post). `reply_to_inbox` now returns clear errors instead of `platform_error` for Instagram DM text over 1,000 characters (`validation_error`), replies outside the 24-hour messaging window (`outside_messaging_window`), and replies to a comment or message that no longer exists on the platform (`not_found`).
241
+
226
242
  ### 1.32.0 (2026-09-02)
227
243
 
228
244
  - **Added: TikTok Photo Mode title.** The `tiktok` option object on `create_post`, `create_and_publish_post`, and `update_post` accepts `title` (max 90 characters): the title TikTok shows above the caption on photo carousels. TikTok's photo endpoint takes the title and the caption as two separate fields, and until now only the caption (`content`) could be set here, so photo posts created through the MCP always published with an empty title. Video posts have a single caption field and ignore `title`. `get_post` returns it as `tiktok.title`. Same on the companion server (`mcp.omnisocials.com`).
package/build/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, InboxConversation, InboxMessage, Webhook } from "./types.js";
1
+ import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
2
2
  export declare function fetchImageAsBase64(url: string): Promise<{
3
3
  data: string;
4
4
  mimeType: string;
@@ -184,6 +184,27 @@ export interface CreatePostInput {
184
184
  hashtag_set?: string;
185
185
  hashtag_placement?: "caption_append" | "first_comment";
186
186
  hashtag_platforms?: string[];
187
+ /** Route the post through a saved approval workflow (GET /approval-workflows). */
188
+ approval_workflow_id?: string;
189
+ }
190
+ export interface ApprovalWorkflowApprover {
191
+ id: string;
192
+ name: string | null;
193
+ email: string | null;
194
+ }
195
+ export interface ApprovalWorkflowStep {
196
+ order: number;
197
+ name: string;
198
+ require_mode: "any" | "all";
199
+ approvers: ApprovalWorkflowApprover[];
200
+ }
201
+ export interface ApprovalWorkflow {
202
+ id: string;
203
+ name: string;
204
+ workspace_id: number | null;
205
+ steps: ApprovalWorkflowStep[];
206
+ created_at: string;
207
+ updated_at: string;
187
208
  }
188
209
  export declare class OmniSocialsClient {
189
210
  private baseUrl;
@@ -298,6 +319,7 @@ export declare class OmniSocialsClient {
298
319
  name: string;
299
320
  parent_id?: string;
300
321
  }): Promise<ApiResponse<MediaFolder>>;
322
+ listApprovalWorkflows(): Promise<ApiResponse<ApprovalWorkflow[]>>;
301
323
  listHashtagSets(): Promise<ApiResponse<HashtagSet[]>>;
302
324
  createHashtagSet(data: {
303
325
  name: string;
@@ -358,6 +380,7 @@ export declare class OmniSocialsClient {
358
380
  platform?: string;
359
381
  type?: string;
360
382
  unread?: string;
383
+ unanswered?: string;
361
384
  limit?: string;
362
385
  cursor?: string;
363
386
  }): Promise<ApiResponse<InboxConversation[]>>;
@@ -370,6 +393,15 @@ export declare class OmniSocialsClient {
370
393
  text: string;
371
394
  attachment_url?: string;
372
395
  attachment_type?: string;
396
+ include_next?: boolean;
373
397
  }): Promise<ApiResponse<InboxMessage>>;
398
+ getNextUnanswered(params?: {
399
+ platform?: string;
400
+ type?: string;
401
+ order?: string;
402
+ include_read?: string;
403
+ exclude?: string;
404
+ }): Promise<ApiResponse<InboxNextUnanswered | null>>;
374
405
  hideInboxMessage(messageId: string, hide?: boolean): Promise<ApiResponse<InboxMessage>>;
406
+ deleteInboxMessage(messageId: string): Promise<ApiResponse<InboxDeleteResult>>;
375
407
  }
package/build/client.js CHANGED
@@ -350,6 +350,10 @@ export class OmniSocialsClient {
350
350
  async createFolder(data) {
351
351
  return this.request("POST", "/folders", data);
352
352
  }
353
+ // Approval workflows (read-only; configured in the dashboard)
354
+ async listApprovalWorkflows() {
355
+ return this.request("GET", "/approval-workflows");
356
+ }
353
357
  // Hashtag Sets
354
358
  async listHashtagSets() {
355
359
  return this.request("GET", "/hashtag-sets");
@@ -431,12 +435,29 @@ export class OmniSocialsClient {
431
435
  async markInboxRead(conversationId) {
432
436
  return this.request("POST", `/inbox/conversations/${encodeURIComponent(conversationId)}/read`);
433
437
  }
438
+ // `include_next: true` makes the response also carry `next` (the next
439
+ // conversation that needs an answer, same shape as getNextUnanswered data)
440
+ // and `remaining` at the top level.
434
441
  async replyToInboxConversation(conversationId, data) {
435
442
  return this.request("POST", `/inbox/conversations/${encodeURIComponent(conversationId)}/reply`, data);
436
443
  }
437
- // Hide/unhide a reply on one of the workspace's Threads posts (Threads
438
- // only for now). `hide: false` unhides. Needs the inbox:write scope.
444
+ // The next inbox item that needs an answer (GET /inbox/next): `data` is
445
+ // { conversation, message, messages } or null, `remaining` sits next to it.
446
+ // Only unread items by default (include_read: "true" widens it); `exclude`
447
+ // is a comma-separated list of conversation ids to skip for this call.
448
+ async getNextUnanswered(params) {
449
+ return this.request("GET", "/inbox/next", undefined, params);
450
+ }
451
+ // Hide/unhide a comment on one of the workspace's posts (Facebook,
452
+ // Instagram, TikTok, YouTube, Threads). `hide: false` unhides. Needs the
453
+ // inbox:write scope.
439
454
  async hideInboxMessage(messageId, hide = true) {
440
455
  return this.request("POST", `/inbox/messages/${encodeURIComponent(messageId)}/hide`, { hide });
441
456
  }
457
+ // Delete a comment on the platform and from the inbox (Facebook, Instagram,
458
+ // TikTok; YouTube cannot delete other people's comments, hide instead).
459
+ // Replies under it go too. Needs the inbox:write scope.
460
+ async deleteInboxMessage(messageId) {
461
+ return this.request("DELETE", `/inbox/messages/${encodeURIComponent(messageId)}`);
462
+ }
442
463
  }
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.32.0",
32
+ version: "1.34.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);
@@ -9,10 +9,88 @@ const PLATFORM_EMOJI = {
9
9
  x: "🐦",
10
10
  threads: "🧵",
11
11
  };
12
+ const INBOX_PLATFORMS = [
13
+ "instagram",
14
+ "facebook",
15
+ "linkedin",
16
+ "tiktok",
17
+ "youtube",
18
+ "x",
19
+ "threads",
20
+ ];
21
+ function errorResult(error) {
22
+ return {
23
+ content: [
24
+ {
25
+ type: "text",
26
+ text: `Error (${error.code}): ${error.message}`,
27
+ },
28
+ ],
29
+ };
30
+ }
31
+ // Inbox thread rendering shared by get_inbox_conversation, get_next_unanswered
32
+ // and reply_to_inbox (include_next). The post comes first (a comment like
33
+ // "what size is this?" only makes sense with the post in view), then the
34
+ // messages oldest first, each with its inbox id so hide/delete can target it.
35
+ function renderInboxPost(post) {
36
+ if (!post || (!post.caption && !post.url && !post.thumbnail))
37
+ return "";
38
+ let md = `**Post**`;
39
+ if (post.media_type) {
40
+ md += ` _(${String(post.media_type).toLowerCase().replace(/_/g, " ")})_`;
41
+ }
42
+ md += `\n`;
43
+ if (post.caption) {
44
+ md += `> ${post.caption.replace(/\s*\n+\s*/g, " ").slice(0, 600)}\n`;
45
+ }
46
+ if (post.url)
47
+ md += `🔗 ${post.url}\n`;
48
+ if (post.thumbnail)
49
+ md += `🖼️ ${post.thumbnail}\n`;
50
+ return md + `\n`;
51
+ }
52
+ function renderInboxMessages(msgs, highlightId) {
53
+ let md = "";
54
+ for (const m of msgs) {
55
+ const who = m.direction === "outgoing"
56
+ ? "→ You"
57
+ : `← ${m.sender?.name || m.sender?.username || "Them"}`;
58
+ const flags = [];
59
+ if (highlightId && String(m.id) === String(highlightId)) {
60
+ flags.push("**needs a reply**");
61
+ }
62
+ if (m.hidden)
63
+ flags.push("hidden");
64
+ if (m.parent_comment_id)
65
+ flags.push("reply to a comment");
66
+ md += `**${who}** _(${formatDateTime(m.timestamp)})_ \`#${m.id}\`${flags.length ? " · " + flags.join(" · ") : ""}\n${m.text || ""}\n`;
67
+ if (m.attachment?.url) {
68
+ md += `📎 [${m.attachment.type}] ${m.attachment.url}\n`;
69
+ }
70
+ if (m.permalink)
71
+ md += `${m.permalink}\n`;
72
+ md += `\n`;
73
+ }
74
+ return md;
75
+ }
76
+ function renderNextUnanswered(result) {
77
+ const item = result.data;
78
+ if (!item)
79
+ return "Nothing is waiting for an answer.";
80
+ const c = item.conversation;
81
+ const who = c.participant?.name || c.participant?.username || "Unknown";
82
+ let md = `## Next: ${PLATFORM_EMOJI[c.platform] || ""} ${c.platform} ${c.type} from ${who}\n\n`;
83
+ md += `Conversation ID: \`${c.conversation_id}\`\nMessage to answer: \`#${item.message.id}\`\n\n`;
84
+ md += renderInboxPost(item.message.post || c.post);
85
+ md += renderInboxMessages(item.messages, item.message.id);
86
+ md += `_${result.remaining || 0} more waiting._\n\n`;
87
+ md += `_Reply: reply_to_inbox with the conversation ID (add include_next: true to get the next one in the same call). Skip for good: mark_inbox_read. Comments: hide_inbox_comment / delete_inbox_comment with the message id._`;
88
+ return md;
89
+ }
12
90
  export function registerInboxTools(server, getClient) {
13
- server.tool("list_inbox_conversations", "List social inbox conversations (Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs where the workspace has opted into X DMs, and Threads replies on the workspace's posts plus mentions), newest activity first. Threads conversations are comment (replies on the workspace's posts) and mention only, need a Threads connection with the reply-reading permission, and are currently rolling out (disabled on production until Meta approves the permission). Cursor-paginated: pass the returned cursor to get the next page.", {
91
+ server.tool("list_inbox_conversations", "List social inbox conversations (Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs where the workspace has opted into X DMs, and Threads replies on the workspace's posts plus mentions), newest activity first. Cursor-paginated: pass the returned cursor to get the next page. To work through what needs answering one item at a time, use get_next_unanswered instead.", {
14
92
  platform: z
15
- .enum(["instagram", "facebook", "linkedin", "tiktok", "x", "youtube", "threads"])
93
+ .enum(INBOX_PLATFORMS)
16
94
  .optional()
17
95
  .describe("Filter to one platform"),
18
96
  type: z
@@ -23,6 +101,10 @@ export function registerInboxTools(server, getClient) {
23
101
  .boolean()
24
102
  .optional()
25
103
  .describe("Only conversations with unread incoming messages"),
104
+ unanswered: z
105
+ .boolean()
106
+ .optional()
107
+ .describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs within the 24-hour reply window), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answers."),
26
108
  limit: z
27
109
  .number()
28
110
  .optional()
@@ -39,21 +121,16 @@ export function registerInboxTools(server, getClient) {
39
121
  query.type = params.type;
40
122
  if (params.unread !== undefined)
41
123
  query.unread = String(params.unread);
124
+ if (params.unanswered !== undefined) {
125
+ query.unanswered = String(params.unanswered);
126
+ }
42
127
  if (params.limit !== undefined)
43
128
  query.limit = String(params.limit);
44
129
  if (params.cursor)
45
130
  query.cursor = params.cursor;
46
131
  const result = await getClient().listInboxConversations(query);
47
- if (result.error) {
48
- return {
49
- content: [
50
- {
51
- type: "text",
52
- text: `Error (${result.error.code}): ${result.error.message}`,
53
- },
54
- ],
55
- };
56
- }
132
+ if (result.error)
133
+ return errorResult(result.error);
57
134
  const convos = result.data || [];
58
135
  if (!convos.length) {
59
136
  return {
@@ -69,17 +146,20 @@ export function registerInboxTools(server, getClient) {
69
146
  md += `| ${PLATFORM_EMOJI[c.platform] || ""} ${c.platform} | ${c.type} | ${who} | ${c.unread_count || 0} | ${text} | \`${c.conversation_id}\` |\n`;
70
147
  }
71
148
  if (result.pagination?.next_cursor) {
72
- md += `\n_More available — call list_inbox_conversations again with cursor "${result.pagination.next_cursor}"._`;
149
+ md += `\n_More available: pass \`cursor: "${result.pagination.next_cursor}"\` to list_inbox_conversations for the next page._`;
73
150
  }
74
151
  return { content: [{ type: "text", text: md }] };
75
152
  });
76
- server.tool("get_inbox_conversation", "Get the full message history of one inbox conversation, oldest first. Use the conversation_id from list_inbox_conversations.", {
153
+ server.tool("get_inbox_conversation", "Get the full message history of one inbox conversation, oldest first, with the post it belongs to (caption, link, image) for comment/mention threads. Each message shows its inbox id (#id): that id is what hide_inbox_comment and delete_inbox_comment take. Use the conversation_id from list_inbox_conversations. Replies sent from the native apps appear as outgoing messages too.", {
77
154
  conversation_id: z.string().describe("The conversation ID to fetch"),
78
155
  limit: z
79
156
  .number()
80
157
  .optional()
81
158
  .describe("Max messages per page (1-100, default 50)"),
82
- cursor: z.string().optional().describe("Pagination cursor"),
159
+ cursor: z
160
+ .string()
161
+ .optional()
162
+ .describe("Pagination cursor from a previous response"),
83
163
  }, { title: "Get Inbox Conversation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ conversation_id, limit, cursor }) => {
84
164
  const query = {};
85
165
  if (limit !== undefined)
@@ -87,16 +167,8 @@ export function registerInboxTools(server, getClient) {
87
167
  if (cursor)
88
168
  query.cursor = cursor;
89
169
  const result = await getClient().getInboxMessages(conversation_id, query);
90
- if (result.error) {
91
- return {
92
- content: [
93
- {
94
- type: "text",
95
- text: `Error (${result.error.code}): ${result.error.message}`,
96
- },
97
- ],
98
- };
99
- }
170
+ if (result.error)
171
+ return errorResult(result.error);
100
172
  const msgs = result.data || [];
101
173
  if (!msgs.length) {
102
174
  return {
@@ -106,24 +178,11 @@ export function registerInboxTools(server, getClient) {
106
178
  };
107
179
  }
108
180
  let md = `## Conversation \`${conversation_id}\`\n\n`;
109
- for (const m of msgs) {
110
- const who = m.direction === "outgoing"
111
- ? "→ You"
112
- : `← ${m.sender?.name || m.sender?.username || "Them"}`;
113
- // The message id is what hide_inbox_reply takes, so always render it.
114
- const flags = [`id ${m.id}`];
115
- if (m.hidden === true)
116
- flags.push("hidden on Threads");
117
- md += `**${who}** _(${formatDateTime(m.timestamp)})_ \`${flags.join(" · ")}\`\n${m.text || ""}\n`;
118
- if (m.attachment?.url) {
119
- md += `📎 [${m.attachment.type}] ${m.attachment.url}\n`;
120
- }
121
- if (m.permalink)
122
- md += `${m.permalink}\n`;
123
- md += `\n`;
124
- }
181
+ const post = msgs.find((m) => m.post && (m.post.caption || m.post.url))?.post;
182
+ md += renderInboxPost(post);
183
+ md += renderInboxMessages(msgs);
125
184
  if (result.pagination?.next_cursor) {
126
- md += `_More messages — call again with cursor "${result.pagination.next_cursor}"._`;
185
+ md += `_More messages: pass \`cursor: "${result.pagination.next_cursor}"\` for the next page._`;
127
186
  }
128
187
  return { content: [{ type: "text", text: md }] };
129
188
  });
@@ -131,16 +190,8 @@ export function registerInboxTools(server, getClient) {
131
190
  conversation_id: z.string().describe("The conversation ID to mark read"),
132
191
  }, { title: "Mark Inbox Message Read", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ conversation_id }) => {
133
192
  const result = await getClient().markInboxRead(conversation_id);
134
- if (result.error) {
135
- return {
136
- content: [
137
- {
138
- type: "text",
139
- text: `Error (${result.error.code}): ${result.error.message}`,
140
- },
141
- ],
142
- };
143
- }
193
+ if (result.error)
194
+ return errorResult(result.error);
144
195
  return {
145
196
  content: [
146
197
  {
@@ -150,7 +201,7 @@ export function registerInboxTools(server, getClient) {
150
201
  ],
151
202
  };
152
203
  });
153
- server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, X, YouTube, or Threads (Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission; the reply publishes as a native Threads reply). You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost) — a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set — an attachment-only DM is allowed); other platforms are text-only.", {
204
+ server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, YouTube, X, or Threads. You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost); a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set); other platforms are text-only. Set include_next: true to get the next conversation that needs an answer back in the same call (same as get_next_unanswered), so the inbox can be worked through as: get_next_unanswered → draft → reply_to_inbox(include_next) → draft → repeat.", {
154
205
  conversation_id: z.string().describe("The conversation ID to reply to"),
155
206
  text: z
156
207
  .string()
@@ -164,60 +215,118 @@ export function registerInboxTools(server, getClient) {
164
215
  .enum(["image", "video", "audio", "file"])
165
216
  .optional()
166
217
  .describe("Required if attachment_url is set"),
167
- }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ conversation_id, text, attachment_url, attachment_type }) => {
218
+ include_next: z
219
+ .boolean()
220
+ .optional()
221
+ .describe("After sending, also return the next conversation that needs an answer (full thread + post context), or 'nothing waiting'"),
222
+ }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ conversation_id, text, attachment_url, attachment_type, include_next }) => {
168
223
  const result = await getClient().replyToInboxConversation(conversation_id, {
169
224
  text: text ?? "",
170
225
  attachment_url,
171
226
  attachment_type,
227
+ ...(include_next ? { include_next: true } : {}),
172
228
  });
173
- if (result.error) {
174
- return {
175
- content: [
176
- {
177
- type: "text",
178
- text: `Error (${result.error.code}): ${result.error.message}`,
179
- },
180
- ],
181
- };
229
+ if (result.error)
230
+ return errorResult(result.error);
231
+ const m = result.data;
232
+ let md = `Reply sent to \`${conversation_id}\` on ${m?.platform || "the platform"}.`;
233
+ if (include_next) {
234
+ md +=
235
+ `\n\n` +
236
+ renderNextUnanswered({
237
+ data: result.next || null,
238
+ remaining: result.remaining || 0,
239
+ });
182
240
  }
241
+ return { content: [{ type: "text", text: md }] };
242
+ });
243
+ server.tool("get_next_unanswered", "Get the next inbox item that needs an answer, with the whole conversation and the post it is on, in one call. Serves the longest-waiting item first: the customer's latest DM with no reply after it (Instagram/Facebook DMs within the 24-hour reply window only, since Meta refuses later replies), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answered (they are mirrored into the inbox), so a thread a colleague answered on their phone is not served again. Instagram mentions are skipped (no reply path). Only unread items are served by default: to skip one for good, mark_inbox_read it; pass include_read to also see read-but-unanswered items. Looks back 30 days. Loop: get_next_unanswered → draft a reply → reply_to_inbox with include_next: true → repeat until nothing is waiting.", {
244
+ platform: z
245
+ .enum(INBOX_PLATFORMS)
246
+ .optional()
247
+ .describe("Only items from one platform"),
248
+ type: z
249
+ .enum(["dm", "comment", "mention"])
250
+ .optional()
251
+ .describe("Only items of one type"),
252
+ order: z
253
+ .enum(["oldest", "newest"])
254
+ .optional()
255
+ .describe("oldest (default): the item that has waited longest first; newest: the most recent first"),
256
+ include_read: z
257
+ .boolean()
258
+ .optional()
259
+ .describe("Also serve items that were marked read but never answered"),
260
+ exclude: z
261
+ .string()
262
+ .optional()
263
+ .describe("Comma-separated conversation ids to leave out of this call (a temporary skip; up to 100)"),
264
+ }, { title: "Get Next Unanswered", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
265
+ const query = {};
266
+ if (params.platform)
267
+ query.platform = params.platform;
268
+ if (params.type)
269
+ query.type = params.type;
270
+ if (params.order)
271
+ query.order = params.order;
272
+ if (params.include_read !== undefined) {
273
+ query.include_read = String(params.include_read);
274
+ }
275
+ if (params.exclude)
276
+ query.exclude = params.exclude;
277
+ const result = await getClient().getNextUnanswered(query);
278
+ if (result.error)
279
+ return errorResult(result.error);
280
+ return {
281
+ content: [{ type: "text", text: renderNextUnanswered(result) }],
282
+ };
283
+ });
284
+ const hideCommentSchema = {
285
+ message_id: z
286
+ .string()
287
+ .describe("The inbox message id of the comment (the #id in the thread)"),
288
+ hide: z
289
+ .boolean()
290
+ .optional()
291
+ .describe("true (default) to hide, false to unhide"),
292
+ };
293
+ const hideComment = async ({ message_id, hide, }) => {
294
+ const result = await getClient().hideInboxMessage(message_id, hide !== false);
295
+ if (result.error)
296
+ return errorResult(result.error);
183
297
  const m = result.data;
184
298
  return {
185
299
  content: [
186
300
  {
187
301
  type: "text",
188
- text: `Reply sent to \`${conversation_id}\`${m?.platform ? ` on ${m.platform}` : ""}.`,
302
+ text: `Comment \`#${m?.id ?? message_id}\` on ${m?.platform || "the platform"} is now ${m?.hidden ? "hidden" : "visible"}.`,
189
303
  },
190
304
  ],
191
305
  };
192
- });
193
- server.tool("hide_inbox_reply", "Hide (or unhide) a reply someone left on one of the workspace's Threads posts, as the post owner. Threads only for now, and only incoming top-level replies can be hidden (Threads does not allow hiding nested replies). The message keeps its place in the conversation; its `hidden` flag flips. Use the message id shown by get_inbox_conversation. Errors: 400 unsupported_platform (not an incoming Threads reply), 400 not_hideable (nested reply, or Threads refused), 401 reauth_required (the Threads connection lacks the reply permission; reconnect Threads under Settings → Organisation → Workspaces). Threads inbox features are currently rolling out (disabled on production until Meta approves the permission). Requires the inbox:write scope.", {
306
+ };
307
+ server.tool("hide_inbox_comment", "Hide (or unhide) a comment someone left on one of the workspace's posts, on the platform, as the post owner. Facebook, Instagram, TikTok, YouTube and Threads comments. On YouTube, hide sets the comment to 'rejected' (removed from public view together with its replies) and unhide publishes it again. Takes the message id (the #id shown by get_inbox_conversation and get_next_unanswered), not the conversation id. A hidden comment no longer counts as unanswered. A 403 reconnect_required means the account was connected without the comment-moderation permission and must be reconnected in the dashboard; relay that to the user rather than retrying.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
308
+ // Older name from 1.28.0 (when hiding was Threads-only). Kept so existing
309
+ // setups keep working; same call, same platforms as hide_inbox_comment.
310
+ server.tool("hide_inbox_reply", "Older name for hide_inbox_comment, kept so existing setups keep working; prefer hide_inbox_comment. Same behaviour: hide (or unhide with hide: false) a comment someone left on one of the workspace's posts, as the post owner, on Facebook, Instagram, TikTok, YouTube or Threads. Takes the message id (the #id in the thread), not the conversation id. A 403 reconnect_required means the account must be reconnected in the dashboard to grant the comment-moderation permission.", hideCommentSchema, { title: "Hide Inbox Comment (older name)", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
311
+ server.tool("delete_inbox_comment", "Permanently delete a comment someone left on one of the workspace's posts, on the platform and from the inbox. Facebook, Instagram and TikTok comments only (YouTube's API cannot delete other people's comments: use hide_inbox_comment there). Replies under the comment are deleted with it. This cannot be undone: confirm with the user before calling. Takes the message id (the #id in the thread), not the conversation id. A 403 reconnect_required means the account must be reconnected in the dashboard to grant the moderation permission.", {
194
312
  message_id: z
195
313
  .string()
196
- .describe("The inbox message id of the reply (from get_inbox_conversation)"),
197
- hide: z
198
- .boolean()
199
- .optional()
200
- .describe("true (default) hides the reply; false unhides it"),
201
- }, async ({ message_id, hide }) => {
202
- const result = await getClient().hideInboxMessage(message_id, hide ?? true);
203
- if (result.error) {
204
- return {
205
- content: [
206
- {
207
- type: "text",
208
- text: `Error (${result.error.code}): ${result.error.message}`,
209
- },
210
- ],
211
- };
212
- }
213
- const hidden = result.data?.hidden === true;
314
+ .describe("The inbox message id of the comment (the #id in the thread)"),
315
+ }, { title: "Delete Inbox Comment", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ message_id }) => {
316
+ const result = await getClient().deleteInboxMessage(message_id);
317
+ if (result.error)
318
+ return errorResult(result.error);
319
+ const d = result.data;
320
+ const replies = Array.isArray(d?.removed_reply_ids)
321
+ ? d.removed_reply_ids.length
322
+ : 0;
214
323
  return {
215
324
  content: [
216
325
  {
217
326
  type: "text",
218
- text: hidden
219
- ? `Reply \`${message_id}\` is now hidden on Threads.`
220
- : `Reply \`${message_id}\` is now visible on Threads again.`,
327
+ text: `Comment \`#${d?.id ?? message_id}\` deleted${replies
328
+ ? ` together with ${replies} repl${replies === 1 ? "y" : "ies"}`
329
+ : ""}.`,
221
330
  },
222
331
  ],
223
332
  };
@@ -72,7 +72,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
72
72
  filename: z.string().optional().describe("Optional filename for the uploaded media"),
73
73
  name: z.string().optional().describe('Human-readable label so you can find this asset by name later instead of re-uploading it, e.g. "pp-play5get50". Strongly recommended on every upload.'),
74
74
  folder: z.string().optional().describe('Optional folder name to file this asset under (created at the top level if it does not exist), e.g. "win-graphics". See list_folders.'),
75
- }, { title: "Upload Media", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
75
+ }, { title: "Upload Media", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
76
76
  let result;
77
77
  if (params.base64_data && params.mime_type) {
78
78
  result = await getClient().uploadMediaFromBase64({
@@ -159,7 +159,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
159
159
  media_id: z.string().optional().describe("Id of an already-uploaded library item to check."),
160
160
  size_bytes: z.number().optional().describe("File size in bytes (use with mime when you already know them)."),
161
161
  mime: z.string().optional().describe("MIME type, e.g. 'video/mp4' (use with size_bytes)."),
162
- }, { title: "Check Media Compatibility", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
162
+ }, { title: "Check Media Compatibility", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (params) => {
163
163
  if (!params.url && !params.media_id && !(params.size_bytes && params.mime)) {
164
164
  return {
165
165
  content: [{ type: "text", text: "Error: Provide one of 'url', 'media_id', or 'size_bytes' + 'mime'." }],
@@ -555,6 +555,57 @@ export function registerPostTools(server, getClient) {
555
555
  if (firstCommentRows.length) {
556
556
  md += `\n\n### First Comments\n\n${firstCommentRows.join("\n")}\n`;
557
557
  }
558
+ // Per-platform publish options read-back (TikTok cover frame, privacy,
559
+ // disclosures; YouTube title/privacy/tags; Pinterest board/title/link),
560
+ // so a caller can verify what was set without opening the dashboard.
561
+ // Only values that were actually chosen are listed.
562
+ const optionRows = [];
563
+ const tk = p.tiktok && typeof p.tiktok === "object" ? p.tiktok : {};
564
+ if (typeof tk.title === "string" && tk.title)
565
+ optionRows.push(["TikTok title", tk.title]);
566
+ if (typeof tk.privacy_level === "string" && tk.privacy_level)
567
+ optionRows.push(["TikTok privacy", tk.privacy_level]);
568
+ const coverMs = Number(tk.video_cover_timestamp_ms) || 0;
569
+ if (coverMs > 0)
570
+ optionRows.push(["TikTok cover frame", `${coverMs} ms (${(coverMs / 1000).toFixed(1)}s into the video)`]);
571
+ for (const [key, label] of [
572
+ ["disable_comment", "TikTok comments"],
573
+ ["disable_duet", "TikTok duet"],
574
+ ["disable_stitch", "TikTok stitch"],
575
+ ]) {
576
+ if (tk[key] === true)
577
+ optionRows.push([label, "disabled"]);
578
+ }
579
+ for (const [key, label] of [
580
+ ["is_aigc", "TikTok AI-generated label"],
581
+ ["brand_content_toggle", "TikTok paid partnership"],
582
+ ["brand_organic_toggle", "TikTok own-brand disclosure"],
583
+ ["auto_add_music", "TikTok auto-add music"],
584
+ ]) {
585
+ if (tk[key] === true)
586
+ optionRows.push([label, "on"]);
587
+ }
588
+ const yt = p.youtube && typeof p.youtube === "object" ? p.youtube : {};
589
+ if (typeof yt.title === "string" && yt.title)
590
+ optionRows.push(["YouTube title", yt.title]);
591
+ if (typeof yt.privacy_status === "string" && yt.privacy_status)
592
+ optionRows.push(["YouTube privacy", yt.privacy_status]);
593
+ if (Array.isArray(yt.tags) && yt.tags.length)
594
+ optionRows.push(["YouTube tags", yt.tags.join(", ")]);
595
+ if (yt.made_for_kids === true)
596
+ optionRows.push(["YouTube made for kids", "yes"]);
597
+ const pin = p.pinterest && typeof p.pinterest === "object" ? p.pinterest : {};
598
+ if (typeof pin.board_id === "string" && pin.board_id)
599
+ optionRows.push(["Pinterest board", pin.board_id]);
600
+ if (typeof pin.title === "string" && pin.title)
601
+ optionRows.push(["Pinterest title", pin.title]);
602
+ if (typeof pin.link === "string" && pin.link)
603
+ optionRows.push(["Pinterest link", pin.link]);
604
+ if (optionRows.length) {
605
+ md += `\n\n### Platform Options\n\n| Option | Value |\n|--------|-------|\n`;
606
+ for (const [k, v] of optionRows)
607
+ md += `| ${k} | ${v.replace(/\|/g, "\\|")} |\n`;
608
+ }
558
609
  // LinkedIn multi-image style: nested under each channel's options
559
610
  // object by the API. Only rendered when the post opts out of the
560
611
  // default PDF document carousel.
@@ -709,6 +760,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
709
760
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread instead of a single post. Provide 2 to 25 parts; parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids (from upload_media) or media_urls. When set, the Threads caption is taken from part 1 and content is ignored for Threads. For a single post, omit thread_parts and use content."),
710
761
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
711
762
  google_business: GOOGLE_BUSINESS_OPTIONS,
763
+ approval_workflow_id: z.string().optional().describe("Route the post through a saved approval workflow (id from list_approval_workflows). The post is created as `in_approval` instead of `scheduled`: its approvers are notified, they review it on the Approvals page, and it publishes at scheduled_at only once the workflow's last step approves it (or approve_post). Use this whenever the user wants to review/approve agent-created posts before they go out. Requires scheduled_at; not allowed with publish_now (use create_post, never create_and_publish_post). A workflow with no approvers on its first step is rejected with validation_error."),
712
764
  }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
713
765
  const result = await getClient().createPost({ ...params, source: "mcp" });
714
766
  if (result.error) {
@@ -731,18 +783,26 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
731
783
  try {
732
784
  const schedAt = p.schedule_at ?? p.scheduled_at;
733
785
  const channels = selectedChannels(p);
734
- let md = `## Draft Created (not yet published)\n\n`;
786
+ const inApproval = p.status === "in_approval";
787
+ let md = inApproval
788
+ ? `## Post Submitted for Approval\n\n`
789
+ : `## Draft Created (not yet published)\n\n`;
735
790
  md += `| Field | Value |\n`;
736
791
  md += `|-------|-------|\n`;
737
792
  md += `| **ID** | \`${p.id}\` |\n`;
738
793
  md += `| **Status** | ${capitalize(p.status || "draft")} |\n`;
794
+ if (inApproval)
795
+ md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
739
796
  if (schedAt)
740
- md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
797
+ md += `| **${inApproval ? "Publishes (once approved)" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
741
798
  if (channels.length)
742
799
  md += `| **Channels** | ${channels.join(", ")} |\n`;
743
800
  if (appUrl)
744
801
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
745
802
  md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
803
+ if (inApproval) {
804
+ md += `_The approvers have been notified. The post publishes at the time above as soon as the workflow's last step approves it (Approvals page in OmniSocials, or approve_post). Nothing else is needed from the user until then._\n\n`;
805
+ }
746
806
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
747
807
  for (const w of result.warnings) {
748
808
  md += `⚠️ ${w.message}\n`;
@@ -857,7 +917,7 @@ Do NOT call without required media — it will fail.`, {
857
917
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread (2 to 25 parts). Parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids or media_urls. The Threads caption is taken from part 1."),
858
918
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
859
919
  google_business: GOOGLE_BUSINESS_OPTIONS,
860
- }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
920
+ }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async (params) => {
861
921
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
862
922
  if (result.error) {
863
923
  return {
@@ -1011,7 +1071,7 @@ Do NOT call without required media — it will fail.`, {
1011
1071
 
1012
1072
  IMPORTANT: Before publishing, verify the post has all required media. If publishing a story or reel, ensure media was attached when the post was created/updated. If it's missing, use update_post to add media first, or inform the user.`, {
1013
1073
  id: z.string().describe("The post ID to publish"),
1014
- }, { title: "Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
1074
+ }, { title: "Publish Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ id }) => {
1015
1075
  const result = await getClient().publishPost(id);
1016
1076
  if (result.error) {
1017
1077
  return {
@@ -1037,7 +1097,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
1037
1097
 
1038
1098
  The retry runs asynchronously (usually within a few minutes). Poll \`get_post\` afterwards: on success the status becomes \`published\` and \`published_urls\` gains the platform's live URL; on another failure the platform's entry reappears under errors. Each platform can be retried at most 3 times — after that, recreate the post. Note \`publish_post\` refuses failed posts; this is the tool for them.`, {
1039
1099
  id: z.string().describe("The failed or partially failed post ID to retry"),
1040
- }, { title: "Retry Failed Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
1100
+ }, { title: "Retry Failed Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ id }) => {
1041
1101
  const result = await getClient().retryPost(id);
1042
1102
  if (result.error) {
1043
1103
  return {
@@ -1063,13 +1123,45 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
1063
1123
  content: [{ type: "text", text: md }],
1064
1124
  };
1065
1125
  });
1126
+ server.tool("list_approval_workflows", `List the approval workflows this workspace can use (company-wide ones plus workspace-bound ones), with their steps and named approvers. Workflows are created in the OmniSocials dashboard (Approvals); this tool only lists them.
1127
+
1128
+ Pass a workflow's id as \`approval_workflow_id\` on \`create_post\` to hold a scheduled post for review: it is created as \`in_approval\`, the approvers are notified, and it publishes at \`scheduled_at\` once the last step approves. Call this first whenever the user wants agent-created posts to be reviewed/approved before they go out; if the list is empty, tell the user to create a workflow under Approvals in the dashboard.`, {}, { title: "List Approval Workflows", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
1129
+ const result = await getClient().listApprovalWorkflows();
1130
+ if (result.error) {
1131
+ return {
1132
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1133
+ };
1134
+ }
1135
+ const items = Array.isArray(result.data) ? result.data : [];
1136
+ if (!items.length) {
1137
+ return {
1138
+ content: [{
1139
+ type: "text",
1140
+ text: "No approval workflows yet. The user can create one in the OmniSocials dashboard under Approvals (choose the approvers per step); then create_post with approval_workflow_id routes posts through it.",
1141
+ }],
1142
+ };
1143
+ }
1144
+ let md = `## Approval Workflows (${items.length})\n\n`;
1145
+ for (const w of items) {
1146
+ md += `### \`${w.id}\` ${w.name}${w.workspace_id ? "" : " _(company-wide)_"}\n\n`;
1147
+ for (const s of w.steps || []) {
1148
+ const approvers = (s.approvers || []).map((a) => a.name || a.email || a.id).join(", ") || "*(no approvers)*";
1149
+ md += `- Step ${s.order}: ${s.name || "Review"} — ${s.require_mode === "all" ? "all must approve" : "any one approves"}: ${approvers}\n`;
1150
+ }
1151
+ md += `\n`;
1152
+ }
1153
+ md += `Use create_post with approval_workflow_id="<id>" (plus scheduled_at) to submit a post for review.`;
1154
+ return {
1155
+ content: [{ type: "text", text: md }],
1156
+ };
1157
+ });
1066
1158
  server.tool("approve_post", `Approve the current step of a post's approval workflow. Only works on a post with \`approval_status: "pending"\` (post status \`in_approval\`) — check \`get_post\` first.
1067
1159
 
1068
1160
  IMPORTANT: this only succeeds if the connected user is a listed approver for the workflow's CURRENT step — steps approve in order, so being an approver on a later step is not enough yet. A \`forbidden\` error means the user isn't (or isn't yet) an approver for this step; tell them who needs to approve instead, don't retry.
1069
1161
 
1070
1162
  If this is the workflow's last step, the post is finalized immediately: it moves to \`scheduled\` (future time) or \`posting\` (due now/in the past). If steps remain, the post stays \`in_approval\` and the next step's approvers are notified — call this again once they've approved, or use \`reject_post\` to stop the workflow instead.`, {
1071
1163
  id: z.string().describe("The post ID to approve (must have approval_status 'pending')"),
1072
- }, { title: "Approve Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
1164
+ }, { title: "Approve Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ id }) => {
1073
1165
  const result = await getClient().approvePost(id);
1074
1166
  if (result.error) {
1075
1167
  return {
@@ -1103,7 +1195,7 @@ IMPORTANT: this only succeeds if the connected user is a listed approver for the
1103
1195
  .max(2000)
1104
1196
  .optional()
1105
1197
  .describe("Optional reason for the rejection, shown to the requester and other approvers."),
1106
- }, { title: "Reject Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id, comment }) => {
1198
+ }, { title: "Reject Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, comment }) => {
1107
1199
  const result = await getClient().rejectPost(id, comment);
1108
1200
  if (result.error) {
1109
1201
  return {
package/build/types.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface ApiResponse<T = unknown> {
19
19
  };
20
20
  conversation_id?: string;
21
21
  marked_read?: number;
22
+ next?: InboxNextUnanswered | null;
23
+ remaining?: number;
22
24
  }
23
25
  export interface InboxParticipant {
24
26
  id: string;
@@ -26,6 +28,16 @@ export interface InboxParticipant {
26
28
  username: string;
27
29
  profile_picture: string | null;
28
30
  }
31
+ /** The post a comment/mention thread belongs to. `url` is the public link to
32
+ * the post and `media_type` the platform's own label (IMAGE, VIDEO,
33
+ * CAROUSEL_ALBUM, ...); both nullable. null on DM conversations. */
34
+ export interface InboxPostContext {
35
+ id: string | null;
36
+ caption: string | null;
37
+ thumbnail: string | null;
38
+ url: string | null;
39
+ media_type: string | null;
40
+ }
29
41
  export interface InboxConversation {
30
42
  conversation_id: string;
31
43
  platform: "instagram" | "facebook" | "linkedin" | "tiktok" | "x" | "youtube" | "threads";
@@ -39,11 +51,7 @@ export interface InboxConversation {
39
51
  timestamp: string;
40
52
  is_read: boolean;
41
53
  };
42
- post: {
43
- id: string | null;
44
- caption: string | null;
45
- thumbnail: string | null;
46
- } | null;
54
+ post: InboxPostContext | null;
47
55
  }
48
56
  export interface InboxMessage {
49
57
  id: string;
@@ -57,8 +65,9 @@ export interface InboxMessage {
57
65
  is_replied: boolean;
58
66
  reaction: string | null;
59
67
  parent_comment_id: string | null;
60
- /** Threads replies only: true when the reply is hidden on Threads (flip it
61
- * with hide_inbox_reply). null for every other platform. */
68
+ /** Comments on every comment platform (Facebook, Instagram, TikTok,
69
+ * YouTube, Threads): true when the comment is hidden on the platform (flip
70
+ * it with hide_inbox_comment). null for DMs. */
62
71
  hidden?: boolean | null;
63
72
  /** Link to the reply or mentioning post on the platform, when known. */
64
73
  permalink?: string | null;
@@ -70,11 +79,24 @@ export interface InboxMessage {
70
79
  type: "image" | "video" | "audio" | "file";
71
80
  } | null;
72
81
  sender: InboxParticipant;
73
- post: {
74
- id: string | null;
75
- caption: string | null;
76
- thumbnail: string | null;
77
- } | null;
82
+ post: InboxPostContext | null;
83
+ }
84
+ /** What GET /inbox/next serves (and what `reply_to_inbox` returns as `next`
85
+ * when `include_next` is set). `message` is the unanswered incoming item: its
86
+ * `id` is what hide/delete take, its `conversation_id` is what reply takes.
87
+ * `messages` is the thread oldest first (the most recent 50, plus the target
88
+ * and its parent when they fall outside that window). */
89
+ export interface InboxNextUnanswered {
90
+ conversation: InboxConversation;
91
+ message: InboxMessage;
92
+ messages: InboxMessage[];
93
+ }
94
+ /** Response of DELETE /inbox/messages/:id. Replies under the comment are
95
+ * removed with it; their inbox ids are listed in `removed_reply_ids`. */
96
+ export interface InboxDeleteResult {
97
+ id: string;
98
+ conversation_id: string;
99
+ removed_reply_ids: string[];
78
100
  }
79
101
  /** One media entry on a post — a plain URL string, or an object carrying the
80
102
  * url plus an optional accessibility description (alt text). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.32.0",
3
+ "version": "1.34.0",
4
4
  "mcpName": "io.github.omnisocials/mcp-server",
5
5
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
6
6
  "type": "module",
@@ -53,7 +53,8 @@
53
53
  },
54
54
  "overrides": {
55
55
  "zod": "4.4.0",
56
- "fast-uri": "3.1.1",
57
- "hono": "4.12.18"
56
+ "fast-uri": "^3.1.7",
57
+ "hono": "^4.13.7",
58
+ "qs": "^6.16.0"
58
59
  }
59
60
  }