@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 +23 -7
- package/build/client.d.ts +33 -1
- package/build/client.js +23 -2
- package/build/index.js +1 -1
- package/build/tools/inbox.js +196 -87
- package/build/tools/media.js +2 -2
- package/build/tools/posts.js +99 -7
- package/build/types.d.ts +34 -12
- package/package.json +4 -3
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 (
|
|
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 (
|
|
192
|
+
### Social Inbox (8 tools)
|
|
192
193
|
|
|
193
194
|
| Tool | Description |
|
|
194
195
|
|------|-------------|
|
|
195
|
-
| `list_inbox_conversations` | List conversations
|
|
196
|
-
| `
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
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
|
-
//
|
|
438
|
-
//
|
|
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
|
+
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);
|
package/build/tools/inbox.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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: `
|
|
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("
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
219
|
-
? `
|
|
220
|
-
:
|
|
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
|
};
|
package/build/tools/media.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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'." }],
|
package/build/tools/posts.js
CHANGED
|
@@ -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
|
-
|
|
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 += `|
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
61
|
-
*
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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.
|
|
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.
|
|
57
|
-
"hono": "4.
|
|
56
|
+
"fast-uri": "^3.1.7",
|
|
57
|
+
"hono": "^4.13.7",
|
|
58
|
+
"qs": "^6.16.0"
|
|
58
59
|
}
|
|
59
60
|
}
|