@omnisocials/mcp-server 1.34.1 → 1.39.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 +51 -6
- package/build/client.d.ts +15 -1
- package/build/client.js +24 -0
- package/build/index.js +1 -1
- package/build/metrics.d.ts +10 -2
- package/build/metrics.js +100 -2
- package/build/tools/analytics.js +1 -1
- package/build/tools/inbox.js +17 -8
- package/build/tools/media.js +39 -5
- package/build/tools/posts.js +251 -13
- package/build/tools/webhooks.js +3 -3
- package/build/types.d.ts +165 -6
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ MCP (Model Context Protocol) server for the [OmniSocials](https://omnisocials.co
|
|
|
9
9
|
|
|
10
10
|
## Remote MCP URL (no install)
|
|
11
11
|
|
|
12
|
-
For clients that support remote MCP over HTTP (claude.ai, ChatGPT, Notion, OpenClaw):
|
|
12
|
+
For clients that support remote MCP over HTTP (claude.ai, ChatGPT, Notion, Muse, OpenClaw):
|
|
13
13
|
|
|
14
14
|
```
|
|
15
15
|
https://mcp.omnisocials.com?API_KEY=omsk_live_your_key_here
|
|
@@ -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 (15 tools)
|
|
119
119
|
|
|
120
120
|
| Tool | Description |
|
|
121
121
|
|------|-------------|
|
|
@@ -130,7 +130,9 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
|
|
|
130
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 |
|
|
131
131
|
| `approve_post` | Approve the current step of a post's approval workflow (the connected user must be a listed approver for that step) |
|
|
132
132
|
| `reject_post` | Reject a post's approval workflow, stopping it immediately |
|
|
133
|
+
| `get_post_approval` | Read the approval review of a post: steps, approvers and their decisions, the rejection reason and the comment thread |
|
|
133
134
|
| `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) |
|
|
135
|
+
| `list_pinterest_products` | List the product Pins of the connected Pinterest account (catalog or own Pins), or check one Pin, to tag products with `pinterest.product_tags` |
|
|
134
136
|
| `search_instagram_audio` | Search Instagram's licensed music catalog for a track to attach to a Reel |
|
|
135
137
|
|
|
136
138
|
### Media & folders (6 tools)
|
|
@@ -178,12 +180,12 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
|
|
|
178
180
|
| `list_workspaces` | List all workspaces available in this session |
|
|
179
181
|
| `switch_workspace` | Switch the active workspace **by name** (id or list number also accepted) |
|
|
180
182
|
|
|
181
|
-
### Webhooks (
|
|
183
|
+
### Webhooks (6 tools)
|
|
182
184
|
|
|
183
185
|
| Tool | Description |
|
|
184
186
|
|------|-------------|
|
|
185
187
|
| `list_webhooks` | List all webhooks |
|
|
186
|
-
| `create_webhook` | Create a webhook for event notifications |
|
|
188
|
+
| `create_webhook` | Create a webhook for event notifications (`post.scheduled`, `post.published`, `post.failed`, `post.approved`, `post.rejected`) |
|
|
187
189
|
| `get_webhook` | Get webhook details |
|
|
188
190
|
| `update_webhook` | Update a webhook |
|
|
189
191
|
| `delete_webhook` | Delete a webhook |
|
|
@@ -194,10 +196,10 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
|
|
|
194
196
|
| Tool | Description |
|
|
195
197
|
|------|-------------|
|
|
196
198
|
| `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 (
|
|
199
|
+
| `get_next_unanswered` | The next item that still needs an answer (DMs that can still be answered first, then expired Instagram/Facebook DMs, then comments and mentions oldest first) with the whole thread, the post it is on and its `reply_window`; filter by platform or type, `include_read`, `exclude` |
|
|
198
200
|
| `get_inbox_conversation` | Get a conversation's full message history with the post it belongs to; every message shows its `#id` |
|
|
199
201
|
| `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 |
|
|
202
|
+
| `reply_to_inbox` | Reply to a DM, comment, or mention (existing conversations only); `message_id` picks the comment being answered; `include_next` returns the next unanswered item in the same call |
|
|
201
203
|
| `hide_inbox_comment` | Hide or unhide a comment on one of your posts (Facebook, Instagram, TikTok, YouTube, Threads) |
|
|
202
204
|
| `delete_inbox_comment` | Permanently delete a comment on one of your posts, with the replies under it (Facebook, Instagram, TikTok) |
|
|
203
205
|
| `hide_inbox_reply` | Older name for `hide_inbox_comment`, kept for compatibility |
|
|
@@ -227,6 +229,49 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
227
229
|
|
|
228
230
|
## Changelog
|
|
229
231
|
|
|
232
|
+
### 1.39.0 (2026-10-05)
|
|
233
|
+
|
|
234
|
+
- **Added: tag products on a Pinterest Pin.** `pinterest.product_tags` on `create_post`, `create_and_publish_post` and `update_post` takes up to 24 product Pins of the connected Pinterest account (Pin IDs or Pin links), so people can shop the items in the image. Pinterest accepts a product only when its Pin is public, belongs to the same account and links to a website that account claimed; products of other merchants cannot be tagged. The tags are added right after the Pin is published. A product that Pinterest refuses never fails the post: the other products are still tagged, and `get_post` shows the outcome (`product_tags_result`, with Pinterest's reason for each product that was not tagged).
|
|
235
|
+
- **Added: `list_pinterest_products`.** New tool that lists the product Pins of the connected Pinterest account, with the `pin_id` to use in `pinterest.product_tags`. `source` `catalog` reads the Pinterest catalog (price and stock included) and needs catalog access, which the user gives one time in the OmniSocials composer (Pinterest options, Add products, Connect catalog). `source` `pins` reads the account's own Pins and works on every connection. `check_pin` checks one Pin ID or Pin link instead of listing.
|
|
236
|
+
|
|
237
|
+
### 1.38.0 (2026-10-02)
|
|
238
|
+
|
|
239
|
+
- **Added: read the approval review of a post.** New tool `get_post_approval` returns the review of a post that went through an approval workflow: every step with its approvers and their decisions, the rejection (who, when and the reason) and the comment thread, oldest first. Until now the tools only gave the status, so an assistant saw `rejected` and not who rejected the post or why. Use it on a rejected post to learn what to change before sending it again, or on a post that waits for approval to see who it waits for and what the reviewers wrote. Read-only. A post without an approval workflow returns status `none`. Backed by `GET /posts/{id}/approval` (scope `posts:read`). Same on the companion server (`mcp.omnisocials.com`).
|
|
240
|
+
- **Added: webhook events `post.approved` and `post.rejected`.** `create_webhook` and `update_webhook` take them next to `post.scheduled`, `post.published` and `post.failed`. `post.approved` fires when the last step of a post's approval workflow is approved (the post moves to `scheduled`, or `posting` when its publish time already passed). `post.rejected` fires when an approver rejects the post (it will not publish). Both carry one more object, `data.approval`: `{ status, decided_by, reason }` (`reason` is `null` on an approval and when the approver gave none), and their `targets` array is empty. Same on the companion server.
|
|
241
|
+
- **Changed: more approval emails.** A rejection, a reviewer comment, a reviewer edit and the final approval now email the person who requested the approval, and the requester's reply in the review thread emails the reviewers. A post that still waits for approval gets automatic emails near its publish time: the reviewers 24 hours before, and the reviewers plus the requester when the time passes (the post did not publish; a late approval publishes it at once). No tool input or output changed for this.
|
|
242
|
+
- **Changed: X link-post credits count only `http://` and `https://` links.** A post that names a website without a scheme (`brand.com`, `www.brand.com`) no longer uses credits and no longer returns the `x_url_post_credits` warning, because X does not bill it as a link post. A post with a full link still uses 20 credits. The descriptions of `create_post` and `create_and_publish_post` say which links count. Same on the companion server.
|
|
243
|
+
|
|
244
|
+
### 1.37.1 (2026-09-30)
|
|
245
|
+
|
|
246
|
+
- **Changed: Facebook Reels longer than 90 seconds are accepted.** The API no longer refuses a Facebook Reel over 90 seconds at submit: a Reel now follows the 240 minute ceiling of a Facebook feed video. Meta removed the Reel length limit in June 2025 (every Facebook video is a reel now), while its Reels Publishing API guide still lists 3 to 90 seconds. A 3 minute Reel published through the API on 2026-09-30, and Reels of 91 to 117 seconds published before the cap existed. The `create_post` and `create_and_publish_post` descriptions no longer tell the assistant to keep a Facebook Reel under 90 seconds. If Facebook refuses a length at publish time, the Facebook target fails with "Facebook did not accept the length of this video..." and the video can go out as a regular Facebook post (`type: "post"`). Same on the companion server.
|
|
247
|
+
- **Changed: `video_cover` on YouTube Shorts is described as channel-dependent.** YouTube shows the cover on Shorts only on channels where it has enabled custom Shorts thumbnails (rolling out since July 2026, Partner Program channels first); on other channels it stores the cover as the default thumbnail but shows a frame from the video (checked on 15 published Shorts from 5 channels: 3 channels show it, 1 does not). The `video_cover` and `youtube` descriptions now say so, so the assistant says it depends on the channel instead of promising it. The image is now sent at up to 1080 x 1920, YouTube's Shorts thumbnail size. Same on the companion server.
|
|
248
|
+
- **Companion server: `video_cover` added.** The hosted server now accepts `video_cover` on `create_post`, `create_and_publish_post` and `update_post` (`null` removes it), takes `facebook.thumbnail_type` / `thumb_offset` / `cover_url`, and lists the cover under Platform Options in `get_post`. This package has had them since 1.35.0.
|
|
249
|
+
|
|
250
|
+
### 1.37.0 (2026-09-28)
|
|
251
|
+
|
|
252
|
+
- **Added: the original PDF is kept, and LinkedIn receives it.** `upload_media` (url, base64 and the `upload_url` snippet) still splits a PDF into one image slide per page (max 20) and now also keeps the original file. When a LinkedIn post's `media_ids` are exactly that PDF's slides, in order and unchanged, LinkedIn receives the original file as the document: text stays sharp, in-document links work, viewers download the real file, and every page is included even past the 20-slide cap. The upload result names the kept file (`pdf.id`, `pdf.name`, `pdf.url`, `pdf.size_bytes`), the tool output says so, and every slide returned by `list_media` / `get_media` carries `pdf: { id, name, url, page, total_pages, rendered_pages }`. Same on the companion server (`mcp.omnisocials.com`).
|
|
253
|
+
- **Added: `pdf_mode` on `upload_media`.** `"slides"` (default) keeps one image media item per page, one Media ID each. `"document"` keeps the PDF as ONE media item (`type: "document"`, its page images in `pdf.pages`); pass its single Media ID in `media_ids` and the post gets every page, in order. The tool output explains which shape came back, and `list_media` shows document items as "Document (PDF, N pages)". Same on the companion server (`mcp.omnisocials.com`), where the `upload_url` snippet takes it as a form field (`-F pdf_mode=document`).
|
|
254
|
+
- **Added: `document_source` on the `linkedin` / `linkedin_page` options** of `create_post`, `create_and_publish_post` and `update_post`. `"original_pdf"` (default) sends the kept original when the images are its unchanged pages; `"slides"` rebuilds the document from the slide images instead (a flattened copy). On `update_post`: `"slides"` sets it, `"original_pdf"`/`null` reverts, omitted keeps the current value. `get_post` prints "document source: rebuilt from the slide images" when a channel opted out. Ignored when the images are not a PDF's pages, when `carousel_as_images` is true, and for 0-1 images, videos and polls. Same on the companion server.
|
|
255
|
+
|
|
256
|
+
### 1.36.0 (2026-09-24)
|
|
257
|
+
|
|
258
|
+
- **Added: `reply_to_inbox` takes `message_id`.** Every comment on a post shares one inbox conversation, and the reply endpoint always answered the newest incoming comment on that post, so a reply drafted for one person could land under a different person's comment. Pass `message_id` (the "Message to answer" `#id` from `get_next_unanswered`, or a `#id` from `get_inbox_conversation`) and the reply goes under exactly that comment. Without it the old behaviour is kept for compatibility (newest comment). Ignored for DMs. A `404 not_found` means the id is not an incoming message of that conversation. The tool description and the `get_next_unanswered` output tell the assistant to always pass it for comments. Same on the companion server (`mcp.omnisocials.com`).
|
|
259
|
+
- **Changed: `get_next_unanswered` serves DMs first and no longer drops expired ones.** A customer's Facebook DM ("How do I buy and where please") was never served: DMs older than 24 hours were filtered out and the queue was oldest-first across everything, so a fresh DM sat behind a three-week comment backlog until Meta's reply window closed. The order is now: Instagram/Facebook DMs inside Meta's 24-hour window (the one closing soonest first) and X DMs, then Instagram/Facebook DMs whose window has closed, then comments and mentions oldest first (`order: "newest"` reverses within each group). Each item carries `reply_window: { open, closes_at }` (only Instagram/Facebook DMs have a window; everything else is `open: true, closes_at: null`). When `open` is false the tool prints a warning that OmniSocials cannot reply and that the user must answer from the Instagram or Facebook app (mirrored into the inbox) or skip with `mark_inbox_read`. `list_inbox_conversations` with `unanswered: true` now includes those expired DM conversations too. Same on the companion server.
|
|
260
|
+
- **Changed: `reply_to_inbox` checks the 24-hour window before sending.** A `422 outside_messaging_window` on an Instagram/Facebook DM is now raised before the platform call (it was only mapped from Meta's error afterwards). Nothing is sent, the message names when the window closed, and the tool description tells the assistant not to retry. Same on the companion server.
|
|
261
|
+
- **Changed: `hide_inbox_comment` explains a 502 `hide_not_applied`.** After an Instagram hide or unhide the API reads the comment back and reports Instagram's own `hidden` state. When Instagram accepted the call but the comment is still in the old state, the API answers 502 `hide_not_applied` and the inbox row is left unchanged (a hide had been reported as done while the comment stayed public). This happens with comments Instagram shows under "Comments from Facebook" on a reel that is also shared to Facebook: they live on Facebook and Instagram's hide does not reach them. The tool description tells the assistant to have the user hide it in the Instagram or Facebook app and not retry. Same on the companion server.
|
|
262
|
+
- **Changed: Facebook `reach` is absent, never 0, in `get_post_analytics` and `get_bulk_post_analytics`.** Meta retired Facebook post reach on 2026-06-15 and the replacement metric it documents (`post_total_media_view_unique`) returns 0 for every Page post while views and clicks keep counting, so the API had stored and returned a false 0 for every Facebook post. The key is now left out, and a row collected before 2026-09-27 that still carries `reach: 0` is dropped by the API. The tool description tells the assistant to treat a missing `reach` as "not reported" and to sum reach only over the platforms that carry it (Instagram, LinkedIn, TikTok with the Business API). Same on the companion server (`mcp.omnisocials.com`).
|
|
263
|
+
|
|
264
|
+
### 1.35.1 (2026-09-22)
|
|
265
|
+
|
|
266
|
+
- **Changed: `reply_to_inbox` explains a 422 `reply_not_allowed`.** The API answers 422 instead of 502 when the platform refuses replies to an item for good (Facebook `#1705`: comment on a share or boosted copy of the post, or the commenter restricts replies; YouTube: the thread's `canReply` is false). The item is dropped from `get_next_unanswered` automatically; relay it, do not retry.
|
|
267
|
+
- **Changed: `hide_inbox_comment` explains a 422 `cannot_hide`.** Facebook does not let the Page hide some comments (the commenter blocked the Page, their account is deactivated or restricted, or the comment sits on a shared copy of the post); the API now reads the comment back and answers 422 when Facebook's `can_hide` is false, instead of a 502 on every attempt. Retrying never changes it; use `mark_inbox_read` if the item should stop coming up.
|
|
268
|
+
|
|
269
|
+
### 1.35.0 (2026-09-17)
|
|
270
|
+
|
|
271
|
+
- **Added: video covers.** `create_post`, `create_and_publish_post` and `update_post` accept `video_cover` for a post whose media is one video: `type: "frame"` with `thumb_offset` (milliseconds into the video) or `type: "custom"` with `cover_url` (JPEG/PNG), plus per-platform `overrides` keyed by platform id (instagram, facebook, linkedin, linkedin_page, tiktok, pinterest, youtube). On `update_post`, `video_cover: null` removes the stored cover. `get_post` shows the cover in use.
|
|
272
|
+
- **Changed: analytics say "not reported" instead of 0.** Metrics a platform never reports at post level (reach on X, saves on Facebook, link clicks on Instagram feed posts, and so on) render as N/A with the reason, and `reach` is null when the platform did not report it, so a zero is never invented.
|
|
273
|
+
- **Changed: `reply_to_inbox` explains a 409 `duplicate_reply`.** The API now refuses the same reply (same text and attachment) sent twice to one comment, or to one DM conversation, within 10 minutes; the tool description tells the assistant that nothing was sent again and to continue with `get_next_unanswered` rather than resend. Guards against a re-invoked tool call after a slow response (a customer's Instagram comment got the identical reply twice, 21 seconds apart).
|
|
274
|
+
|
|
230
275
|
### 1.34.1 (2026-09-14)
|
|
231
276
|
|
|
232
277
|
- **Changed: Threads threads and the Threads inbox are no longer "rolling out".** Meta approved the reply, read-replies and mentions permissions, so `threads.thread_parts` and the Threads inbox work for every Threads connection; accounts connected before 2026-09-14 need a one-time reconnect under Settings -> Organisation -> Workspaces (`400`/`401` errors name the reconnect). Threads location tagging stays rolling out (not approved yet). Same on the companion server.
|
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, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
|
|
1
|
+
import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, BestTimesResult, HashtagSet, LocationSearchResult, PinterestProductCheck, PinterestProductsResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, PostApproval, 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;
|
|
@@ -168,6 +168,8 @@ export interface CreatePostInput {
|
|
|
168
168
|
y: number;
|
|
169
169
|
image_index?: number;
|
|
170
170
|
}>;
|
|
171
|
+
/** Video thumbnail: { type: "frame"|"custom", thumb_offset?, cover_url?, overrides? }. */
|
|
172
|
+
video_cover?: ApiVideoCover;
|
|
171
173
|
pinterest?: Record<string, unknown>;
|
|
172
174
|
youtube?: Record<string, unknown>;
|
|
173
175
|
instagram?: Record<string, unknown>;
|
|
@@ -248,6 +250,7 @@ export declare class OmniSocialsClient {
|
|
|
248
250
|
y: number;
|
|
249
251
|
image_index?: number;
|
|
250
252
|
}>;
|
|
253
|
+
video_cover?: ApiVideoCover | null;
|
|
251
254
|
pinterest?: Record<string, unknown>;
|
|
252
255
|
youtube?: Record<string, unknown>;
|
|
253
256
|
instagram?: Record<string, unknown>;
|
|
@@ -267,11 +270,19 @@ export declare class OmniSocialsClient {
|
|
|
267
270
|
retryPost(id: string): Promise<ApiResponse<RetryPostResult>>;
|
|
268
271
|
approvePost(id: string): Promise<ApiResponse<ApprovePostResult>>;
|
|
269
272
|
rejectPost(id: string, comment?: string): Promise<ApiResponse<RejectPostResult>>;
|
|
273
|
+
getPostApproval(id: string): Promise<ApiResponse<PostApproval>>;
|
|
270
274
|
searchLocations(query?: string, platform?: "instagram" | "threads", coords?: {
|
|
271
275
|
latitude?: number;
|
|
272
276
|
longitude?: number;
|
|
273
277
|
}): Promise<LocationSearchResult>;
|
|
274
278
|
validateLocation(id: string): Promise<ApiResponse<unknown>>;
|
|
279
|
+
listPinterestProducts(params?: {
|
|
280
|
+
source?: "catalog" | "pins";
|
|
281
|
+
product_group_id?: string;
|
|
282
|
+
bookmark?: string;
|
|
283
|
+
page_size?: string;
|
|
284
|
+
}): Promise<PinterestProductsResult>;
|
|
285
|
+
validatePinterestProduct(id: string): Promise<PinterestProductCheck>;
|
|
275
286
|
searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<AudioSearchResult>;
|
|
276
287
|
listMedia(params?: {
|
|
277
288
|
limit?: string;
|
|
@@ -284,6 +295,7 @@ export declare class OmniSocialsClient {
|
|
|
284
295
|
filename?: string;
|
|
285
296
|
name?: string;
|
|
286
297
|
folder?: string;
|
|
298
|
+
pdf_mode?: "slides" | "document";
|
|
287
299
|
}): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
|
|
288
300
|
compatibility?: MediaCompatibility;
|
|
289
301
|
message?: string;
|
|
@@ -294,6 +306,7 @@ export declare class OmniSocialsClient {
|
|
|
294
306
|
filename?: string;
|
|
295
307
|
name?: string;
|
|
296
308
|
folder?: string;
|
|
309
|
+
pdf_mode?: "slides" | "document";
|
|
297
310
|
}): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
|
|
298
311
|
compatibility?: MediaCompatibility;
|
|
299
312
|
message?: string;
|
|
@@ -391,6 +404,7 @@ export declare class OmniSocialsClient {
|
|
|
391
404
|
markInboxRead(conversationId: string): Promise<ApiResponse<unknown>>;
|
|
392
405
|
replyToInboxConversation(conversationId: string, data: {
|
|
393
406
|
text: string;
|
|
407
|
+
message_id?: string;
|
|
394
408
|
attachment_url?: string;
|
|
395
409
|
attachment_type?: string;
|
|
396
410
|
include_next?: boolean;
|
package/build/client.js
CHANGED
|
@@ -285,6 +285,12 @@ export class OmniSocialsClient {
|
|
|
285
285
|
...(comment ? { comment } : {}),
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
|
+
// Reads the review of the post's approval workflow: every step with its
|
|
289
|
+
// approvers and their decisions, the rejection with its reason, and the
|
|
290
|
+
// comment thread. A post without a workflow answers 200 with status "none".
|
|
291
|
+
async getPostApproval(id) {
|
|
292
|
+
return this.request("GET", `/posts/${id}/approval`);
|
|
293
|
+
}
|
|
288
294
|
// Locations (Instagram or Threads place tagging). NOT the standard
|
|
289
295
|
// envelope; see LocationSearchResult: Instagram's degraded-path `error` is
|
|
290
296
|
// a plain string next to `data`; Threads answers with `locations` and a
|
|
@@ -303,6 +309,24 @@ export class OmniSocialsClient {
|
|
|
303
309
|
async validateLocation(id) {
|
|
304
310
|
return this.request("GET", "/locations/validate", undefined, { id });
|
|
305
311
|
}
|
|
312
|
+
// Pinterest product Pins (for `pinterest.product_tags`). NOT the standard
|
|
313
|
+
// envelope; see PinterestProductsResult: `products` on success, a
|
|
314
|
+
// { code, message } `error` object when the list could not be read.
|
|
315
|
+
async listPinterestProducts(params) {
|
|
316
|
+
const query = {};
|
|
317
|
+
if (params?.source)
|
|
318
|
+
query.source = params.source;
|
|
319
|
+
if (params?.product_group_id)
|
|
320
|
+
query.product_group_id = params.product_group_id;
|
|
321
|
+
if (params?.bookmark)
|
|
322
|
+
query.bookmark = params.bookmark;
|
|
323
|
+
if (params?.page_size)
|
|
324
|
+
query.page_size = params.page_size;
|
|
325
|
+
return this.request("GET", "/pinterest/products", undefined, query);
|
|
326
|
+
}
|
|
327
|
+
async validatePinterestProduct(id) {
|
|
328
|
+
return this.request("GET", "/pinterest/products/validate", undefined, { id });
|
|
329
|
+
}
|
|
306
330
|
// Audio (Instagram Reel music). Same nonstandard string-`error` envelope.
|
|
307
331
|
async searchInstagramAudio(query, type) {
|
|
308
332
|
const params = {};
|
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.39.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/metrics.d.ts
CHANGED
|
@@ -16,8 +16,16 @@ type Metrics = Record<string, unknown> | null | undefined;
|
|
|
16
16
|
export declare function getEngagement(platform: string, metrics: Metrics): number;
|
|
17
17
|
/** Best-available impressions ("how many times seen") for a post's metrics. */
|
|
18
18
|
export declare function getImpressions(platform: string, metrics: Metrics): number;
|
|
19
|
-
/** Unique accounts reached
|
|
20
|
-
export declare function getReach(platform: string, metrics: Metrics): number;
|
|
19
|
+
/** Unique accounts reached; null when the platform did not report it. */
|
|
20
|
+
export declare function getReach(platform: string, metrics: Metrics): number | null;
|
|
21
|
+
/** Post-level metrics a platform can never report: platform -> metric key -> reason. */
|
|
22
|
+
export declare const UNSUPPORTED_POST_METRICS: Record<string, Record<string, string>>;
|
|
23
|
+
/** Metrics unavailable for some post types only: platform -> content_type -> metric key -> reason. Empty since Facebook reach moved to the platform-wide map. */
|
|
24
|
+
export declare const UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE: Record<string, Record<string, Record<string, string>>>;
|
|
25
|
+
/** Reason `metricKey` is unavailable on `platform` (refined by `contentType`), or null when it is available. */
|
|
26
|
+
export declare function unsupportedMetricReason(platform: string, metricKey: string, contentType?: string): string | null;
|
|
27
|
+
/** True when the platform can never report `metricKey` for this post; a finite stored value overrides the map. */
|
|
28
|
+
export declare function isMetricUnsupported(platform: string, metricKey: string, metrics?: Metrics): boolean;
|
|
21
29
|
/**
|
|
22
30
|
* Engagement rate as a percentage (0-100), suppressed below the impression
|
|
23
31
|
* floor and capped at 100%.
|
package/build/metrics.js
CHANGED
|
@@ -106,10 +106,108 @@ export function getImpressions(platform, metrics) {
|
|
|
106
106
|
return num(m.views) || num(m.impressions) || num(m.reach);
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
|
-
|
|
109
|
+
// Finite number or null: a missing key stays "no data", never 0.
|
|
110
|
+
const finiteOrNull = (v) => {
|
|
111
|
+
if (v === null || v === undefined || v === "")
|
|
112
|
+
return null;
|
|
113
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
114
|
+
return Number.isFinite(n) ? n : null;
|
|
115
|
+
};
|
|
116
|
+
/** Unique accounts reached; null when the platform did not report it. */
|
|
110
117
|
export function getReach(platform, metrics) {
|
|
111
118
|
const m = metrics || {};
|
|
112
|
-
|
|
119
|
+
for (const key of ["reach", "unique_impressions", "members_reached"]) {
|
|
120
|
+
const v = finiteOrNull(m[key]);
|
|
121
|
+
if (v !== null)
|
|
122
|
+
return v;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
/** Post-level metrics a platform can never report: platform -> metric key -> reason. */
|
|
127
|
+
export const UNSUPPORTED_POST_METRICS = {
|
|
128
|
+
x: {
|
|
129
|
+
reach: "X does not report reach, only impressions.",
|
|
130
|
+
},
|
|
131
|
+
facebook: {
|
|
132
|
+
reach: "Facebook stopped reporting post reach in June 2026: Meta retired the metric and its replacement returns no data.",
|
|
133
|
+
saves: "Facebook does not report saves.",
|
|
134
|
+
},
|
|
135
|
+
instagram: {
|
|
136
|
+
clicks: "Instagram reports link clicks for stories only (link sticker taps).",
|
|
137
|
+
link_clicks: "Instagram reports link clicks for stories only (link sticker taps).",
|
|
138
|
+
},
|
|
139
|
+
linkedin_page: {
|
|
140
|
+
link_clicks: "LinkedIn Pages report total clicks only, not link clicks separately.",
|
|
141
|
+
saves: "LinkedIn Pages do not report saves.",
|
|
142
|
+
},
|
|
143
|
+
threads: {
|
|
144
|
+
reach: "Threads does not report reach for posts.",
|
|
145
|
+
clicks: "Threads reports link clicks at account level only, not per post.",
|
|
146
|
+
link_clicks: "Threads reports link clicks at account level only, not per post.",
|
|
147
|
+
saves: "Threads does not report saves.",
|
|
148
|
+
},
|
|
149
|
+
youtube: {
|
|
150
|
+
reach: "YouTube does not report reach, only views.",
|
|
151
|
+
clicks: "YouTube does not report clicks.",
|
|
152
|
+
link_clicks: "YouTube does not report clicks.",
|
|
153
|
+
saves: "YouTube does not report saves.",
|
|
154
|
+
},
|
|
155
|
+
tiktok: {
|
|
156
|
+
clicks: "TikTok reports bio link clicks at account level only, not per video.",
|
|
157
|
+
link_clicks: "TikTok reports bio link clicks at account level only, not per video.",
|
|
158
|
+
},
|
|
159
|
+
pinterest: {
|
|
160
|
+
reach: "Pinterest does not report reach, only impressions.",
|
|
161
|
+
shares: "Pinterest does not report shares.",
|
|
162
|
+
},
|
|
163
|
+
bluesky: {
|
|
164
|
+
impressions: "Bluesky does not report impressions or views.",
|
|
165
|
+
reach: "Bluesky does not report reach.",
|
|
166
|
+
clicks: "Bluesky does not report clicks.",
|
|
167
|
+
link_clicks: "Bluesky does not report clicks.",
|
|
168
|
+
video_views: "Bluesky does not report video views.",
|
|
169
|
+
engagement_rate: "Engagement rate needs impressions, which Bluesky does not report.",
|
|
170
|
+
},
|
|
171
|
+
mastodon: {
|
|
172
|
+
impressions: "Mastodon does not report impressions or views.",
|
|
173
|
+
reach: "Mastodon does not report reach.",
|
|
174
|
+
clicks: "Mastodon does not report clicks.",
|
|
175
|
+
link_clicks: "Mastodon does not report clicks.",
|
|
176
|
+
saves: "Mastodon does not report saves.",
|
|
177
|
+
video_views: "Mastodon does not report video views.",
|
|
178
|
+
engagement_rate: "Engagement rate needs impressions, which Mastodon does not report.",
|
|
179
|
+
},
|
|
180
|
+
google_business: {
|
|
181
|
+
impressions: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
182
|
+
reach: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
183
|
+
likes: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
184
|
+
comments: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
185
|
+
shares: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
186
|
+
saves: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
187
|
+
clicks: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
188
|
+
link_clicks: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
189
|
+
video_views: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
190
|
+
engagement: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
191
|
+
engagement_rate: "Google discontinued per-post metrics for Business Profiles in 2023.",
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
/** Metrics unavailable for some post types only: platform -> content_type -> metric key -> reason. Empty since Facebook reach moved to the platform-wide map. */
|
|
195
|
+
export const UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE = {};
|
|
196
|
+
/** Reason `metricKey` is unavailable on `platform` (refined by `contentType`), or null when it is available. */
|
|
197
|
+
export function unsupportedMetricReason(platform, metricKey, contentType) {
|
|
198
|
+
const typed = contentType ? UNSUPPORTED_POST_METRICS_BY_CONTENT_TYPE[platform]?.[contentType]?.[metricKey] : undefined;
|
|
199
|
+
if (typed)
|
|
200
|
+
return typed;
|
|
201
|
+
return UNSUPPORTED_POST_METRICS[platform]?.[metricKey] ?? null;
|
|
202
|
+
}
|
|
203
|
+
/** True when the platform can never report `metricKey` for this post; a finite stored value overrides the map. */
|
|
204
|
+
export function isMetricUnsupported(platform, metricKey, metrics) {
|
|
205
|
+
const contentType = typeof metrics?.content_type === "string" ? metrics.content_type : undefined;
|
|
206
|
+
if (!unsupportedMetricReason(platform, metricKey, contentType))
|
|
207
|
+
return false;
|
|
208
|
+
if (metrics && finiteOrNull(metrics[metricKey]) !== null)
|
|
209
|
+
return false;
|
|
210
|
+
return true;
|
|
113
211
|
}
|
|
114
212
|
/**
|
|
115
213
|
* Engagement rate as a percentage (0-100), suppressed below the impression
|
package/build/tools/analytics.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { formatNumber, capitalize, metricRows, metricBreakdownRows, sortMetricLabels } from "../client.js";
|
|
3
3
|
import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
|
|
4
4
|
export function registerAnalyticsTools(server, getClient) {
|
|
5
|
-
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported
|
|
5
|
+
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported per platform: impressions, reach, views, saves, likes, comments, shares, clicks, link_clicks, engagements, and more. A missing key means the platform does not report it: reach is absent (not 0) on X, Pinterest, YouTube, Threads, Bluesky, Mastodon and Facebook (Meta retired Facebook post reach in June 2026 and its replacement metric returns no data; a Facebook row collected before 2026-09-27 may still carry reach: 0, treat it as absent); link_clicks (clicks on links in the post) exists for X posts under 30 days, LinkedIn profile, Facebook feed posts, Pinterest and Instagram stories, while clicks is the platform's broadest click count. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
|
|
6
6
|
post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
|
|
7
7
|
}, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
|
|
8
8
|
const result = await getClient().getPostAnalytics(post_id);
|
package/build/tools/inbox.js
CHANGED
|
@@ -80,11 +80,15 @@ function renderNextUnanswered(result) {
|
|
|
80
80
|
const c = item.conversation;
|
|
81
81
|
const who = c.participant?.name || c.participant?.username || "Unknown";
|
|
82
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}
|
|
83
|
+
md += `Conversation ID: \`${c.conversation_id}\`\nMessage to answer: \`#${item.message.id}\` (pass it as message_id when replying)\n\n`;
|
|
84
|
+
if (item.reply_window?.open === false) {
|
|
85
|
+
const app = c.platform === "instagram" ? "Instagram" : "Facebook";
|
|
86
|
+
md += `⚠️ **Meta's 24-hour reply window for this DM closed ${formatDateTime(item.reply_window.closes_at)}.** OmniSocials cannot send a reply here (reply_to_inbox answers 422 outside_messaging_window). Tell the user to answer it from the ${app} app; that reply is mirrored into the inbox and clears this item. Or mark_inbox_read to skip it.\n\n`;
|
|
87
|
+
}
|
|
84
88
|
md += renderInboxPost(item.message.post || c.post);
|
|
85
89
|
md += renderInboxMessages(item.messages, item.message.id);
|
|
86
90
|
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._`;
|
|
91
|
+
md += `_Reply: reply_to_inbox with the conversation ID and message_id: "${item.message.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
92
|
return md;
|
|
89
93
|
}
|
|
90
94
|
export function registerInboxTools(server, getClient) {
|
|
@@ -104,7 +108,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
104
108
|
unanswered: z
|
|
105
109
|
.boolean()
|
|
106
110
|
.optional()
|
|
107
|
-
.describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs
|
|
111
|
+
.describe("Only conversations that still need an answer: the customer's latest DM has no reply after it (Instagram/Facebook DMs past the 24-hour reply window included; they cannot be answered through the API but the customer is still waiting), or a comment/mention nobody replied to and that is not hidden. Replies typed in the native apps count as answers."),
|
|
108
112
|
limit: z
|
|
109
113
|
.number()
|
|
110
114
|
.optional()
|
|
@@ -201,8 +205,12 @@ export function registerInboxTools(server, getClient) {
|
|
|
201
205
|
],
|
|
202
206
|
};
|
|
203
207
|
});
|
|
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.", {
|
|
208
|
+
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. For a comment or mention, pass message_id (the #id of the comment being answered): comments on one post share a conversation and without it the reply goes under the newest comment on the post. Meta direct-message replies must be within the platform's 24-hour messaging window: a 422 outside_messaging_window means the window has closed, nothing was sent, and the user must answer that DM from the Instagram or Facebook app (mirrored into the inbox) or skip it with mark_inbox_read; do not retry. 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. A 409 duplicate_reply means this exact reply already went out to this conversation in the last 10 minutes (an earlier call that looked slow or failed did succeed) or is being sent right now: nothing was sent again, do NOT retry it — continue with get_next_unanswered. A 422 reply_not_allowed means the platform refuses replies to this item for good (Facebook #1705: the comment sits on a share or boosted copy of the post, or the commenter restricts replies; YouTube: the thread's canReply is false because comments are off on the video or the comment was removed or held for review): the item is dropped from get_next_unanswered automatically, tell the user, and do NOT retry.", {
|
|
205
209
|
conversation_id: z.string().describe("The conversation ID to reply to"),
|
|
210
|
+
message_id: z
|
|
211
|
+
.string()
|
|
212
|
+
.optional()
|
|
213
|
+
.describe("For comment/mention threads: the inbox id of the comment you are answering (the 'Message to answer' #id from get_next_unanswered, or a #id from get_inbox_conversation). Every comment on a post shares one conversation, so without it the reply is posted under the newest comment on the post, which can be a different person than the one you drafted for. Always pass it for comments. Ignored for DMs."),
|
|
206
214
|
text: z
|
|
207
215
|
.string()
|
|
208
216
|
.optional()
|
|
@@ -219,9 +227,10 @@ export function registerInboxTools(server, getClient) {
|
|
|
219
227
|
.boolean()
|
|
220
228
|
.optional()
|
|
221
229
|
.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 }) => {
|
|
230
|
+
}, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async ({ conversation_id, message_id, text, attachment_url, attachment_type, include_next }) => {
|
|
223
231
|
const result = await getClient().replyToInboxConversation(conversation_id, {
|
|
224
232
|
text: text ?? "",
|
|
233
|
+
...(message_id ? { message_id } : {}),
|
|
225
234
|
attachment_url,
|
|
226
235
|
attachment_type,
|
|
227
236
|
...(include_next ? { include_next: true } : {}),
|
|
@@ -240,7 +249,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
240
249
|
}
|
|
241
250
|
return { content: [{ type: "text", text: md }] };
|
|
242
251
|
});
|
|
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.
|
|
252
|
+
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. An item needs an answer when it is the customer's latest DM with no reply after it, or a comment/mention nobody replied to and that is not hidden. Order: DMs that can still be answered first (Instagram/Facebook DMs inside Meta's 24-hour window, the one closing soonest first, plus X DMs), then Instagram/Facebook DMs whose window has closed (reply_window.open = false: OmniSocials cannot reply, tell the user to answer from the native app or skip with mark_inbox_read), then comments and mentions, oldest first. 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 the conversation id, message_id = the served message's #id, and include_next: true → repeat until nothing is waiting.", {
|
|
244
253
|
platform: z
|
|
245
254
|
.enum(INBOX_PLATFORMS)
|
|
246
255
|
.optional()
|
|
@@ -252,7 +261,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
252
261
|
order: z
|
|
253
262
|
.enum(["oldest", "newest"])
|
|
254
263
|
.optional()
|
|
255
|
-
.describe("oldest (default): the item that has waited longest first; newest: the most recent first"),
|
|
264
|
+
.describe("oldest (default): the item that has waited longest first; newest: the most recent first. Applies within each group (answerable DMs, then expired Instagram/Facebook DMs, then comments); the groups keep their order"),
|
|
256
265
|
include_read: z
|
|
257
266
|
.boolean()
|
|
258
267
|
.optional()
|
|
@@ -307,7 +316,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
307
316
|
],
|
|
308
317
|
};
|
|
309
318
|
};
|
|
310
|
-
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. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. 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);
|
|
319
|
+
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. If the comment was already deleted on the platform (its author removed it, etc.), this still succeeds — the response says so — and the item is marked handled; do not retry it. 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. A 422 cannot_hide means Facebook does not let the Page hide this particular comment (the commenter blocked the Page, their account is deactivated or restricted, or the comment sits on a shared copy of the post): retrying never changes it — tell the user, and use mark_inbox_read on the conversation if it should stop coming up. A 502 hide_not_applied means Instagram accepted the call but still reports the comment as visible (this happens with comments shown under 'Comments from Facebook' on a reel shared to Facebook; they live on Facebook and Instagram's hide does not reach them): nothing changed in the inbox, tell the user to hide it in the Instagram or Facebook app, do not retry.", hideCommentSchema, { title: "Hide Inbox Comment", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, hideComment);
|
|
311
320
|
// Older name from 1.28.0 (when hiding was Threads-only). Kept so existing
|
|
312
321
|
// setups keep working; same call, same platforms as hide_inbox_comment.
|
|
313
322
|
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);
|
package/build/tools/media.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fetchImageAsBase64, formatBytes } from "../client.js";
|
|
3
3
|
export function registerMediaTools(server, getClient) {
|
|
4
|
-
server.tool("list_media", "List media files in the workspace. Returns each file's ID, name, folder, type (image/video), and size. To reuse an existing graphic, SEARCH for it by name here FIRST instead of re-uploading — re-uploading the same image creates duplicates. Media IDs from this list can be passed to create_post. Organize assets into folders with create_folder / list_folders.", {
|
|
4
|
+
server.tool("list_media", "List media files in the workspace. Returns each file's ID, name, folder, type (image/video/document — a document is a PDF kept as one item; its ID in media_ids expands into every page), and size. To reuse an existing graphic, SEARCH for it by name here FIRST instead of re-uploading — re-uploading the same image creates duplicates. Media IDs from this list can be passed to create_post. Organize assets into folders with create_folder / list_folders.", {
|
|
5
5
|
limit: z.string().optional().describe("Max results to return"),
|
|
6
6
|
offset: z.string().optional().describe("Offset for pagination"),
|
|
7
7
|
search: z.string().optional().describe('Find a file by name or filename, e.g. "play5get50". Use this before uploading to check if the graphic already exists.'),
|
|
@@ -30,7 +30,16 @@ export function registerMediaTools(server, getClient) {
|
|
|
30
30
|
// `type` may be "video"/"image" (normalized) or a raw MIME like "video/mp4".
|
|
31
31
|
const rawType = typeof m.type === "string" ? m.type : "";
|
|
32
32
|
const isVideo = rawType === "video" || rawType.startsWith("video/");
|
|
33
|
-
const
|
|
33
|
+
const isDocument = rawType === "document";
|
|
34
|
+
// A PDF document item names its page count (pass its ONE id and the
|
|
35
|
+
// post gets every page); a slide rendered from a PDF names its file
|
|
36
|
+
// and page, so the assistant can pass the whole deck in order.
|
|
37
|
+
const pdfTag = m.pdf
|
|
38
|
+
? isDocument
|
|
39
|
+
? ` (PDF, ${Array.isArray(m.pdf.pages) ? m.pdf.pages.length : m.pdf.rendered_pages} pages)`
|
|
40
|
+
: ` (PDF ${m.pdf.name ? `"${m.pdf.name}" ` : ""}page ${m.pdf.page}/${m.pdf.total_pages})`
|
|
41
|
+
: "";
|
|
42
|
+
const type = (isVideo ? "Video" : isDocument ? "Document" : "Image") + pdfTag;
|
|
34
43
|
const folder = m.folder_id ? `\`${m.folder_id}\`` : "—";
|
|
35
44
|
md += `| ${i + 1} | ${type} | ${m.name || m.filename || "—"} | ${folder} | ${(m.size || "—")} | \`${m.id}\` |\n`;
|
|
36
45
|
}
|
|
@@ -57,7 +66,7 @@ export function registerMediaTools(server, getClient) {
|
|
|
57
66
|
});
|
|
58
67
|
server.tool("upload_media", `Upload media to the library. Accepts EITHER a public URL OR base64-encoded image data (e.g. when the user pastes an image directly in chat). Supported: JPEG, PNG, GIF, WebP, MP4, MOV, AVI, and PDF.
|
|
59
68
|
|
|
60
|
-
PDF = carousel: upload a PDF (as a 'url' or as base64_data with mime_type "application/pdf") and it is split into one image slide per page (max 20 pages). The response lists a Media ID for every slide — pass ALL of them, in order, as media_ids to create_post to post the deck as a carousel. On LinkedIn the slides post as a native swipeable DOCUMENT; on Instagram, TikTok, Threads and Pinterest as an image carousel. This is the way to let a user post an existing slide deck (Canva/PowerPoint/Figma exported to PDF) as a carousel.
|
|
69
|
+
PDF = carousel: upload a PDF (as a 'url' or as base64_data with mime_type "application/pdf") and it is split into one image slide per page (max 20 pages). The response lists a Media ID for every slide — pass ALL of them, in order, as media_ids to create_post to post the deck as a carousel. Prefer pdf_mode "document" when the user wants ONE library item for the deck: the response then has a single Media ID whose media_ids entry expands into every page at post time. On LinkedIn the slides post as a native swipeable DOCUMENT made from the ORIGINAL PDF file (the file is kept: text stays sharp, in-document links work, viewers download the real file, and every page is included even past the 20-slide cap) as long as the slides are posted unchanged and in order; on Instagram, TikTok, Threads and Pinterest as an image carousel. Set linkedin.document_source to 'slides' on the post to send a document rebuilt from the slide images instead. This is the way to let a user post an existing slide deck (Canva/PowerPoint/Figma exported to PDF) as a carousel.
|
|
61
70
|
|
|
62
71
|
Size limits: base64 and direct uploads are capped at 100 MB (and base64 inflates the request body, so the practical ceiling is ~75 MB). For anything larger — up to 1 GB — use ONE of: (a) pass a public 'url' and the server fetches it (handles up to 1 GB), or (b) have the user upload the file in the OmniSocials Library UI at https://app.omnisocials.com/library . IMPORTANT: if the user has a large LOCAL file (over ~100 MB) with no public URL, do NOT tell them to compress or shrink it — instead point them to the Library UI link above, which uploads files up to 1 GB directly.
|
|
63
72
|
|
|
@@ -72,6 +81,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
72
81
|
filename: z.string().optional().describe("Optional filename for the uploaded media"),
|
|
73
82
|
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
83
|
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.'),
|
|
84
|
+
pdf_mode: z.enum(["slides", "document"]).optional().describe('PDF uploads only. "slides" (default): one image media item per page, one Media ID each (pass ALL of media_ids to create_post). "document": ONE media item for the whole PDF (type "document"); pass its single Media ID in media_ids and the post gets every page in order. Both keep the original file, which LinkedIn receives. Prefer "document" when the user wants one library item for the deck.'),
|
|
75
85
|
}, { title: "Upload Media", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
|
|
76
86
|
let result;
|
|
77
87
|
if (params.base64_data && params.mime_type) {
|
|
@@ -81,10 +91,17 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
81
91
|
filename: params.filename,
|
|
82
92
|
name: params.name || params.filename,
|
|
83
93
|
folder: params.folder,
|
|
94
|
+
...(params.pdf_mode ? { pdf_mode: params.pdf_mode } : {}),
|
|
84
95
|
});
|
|
85
96
|
}
|
|
86
97
|
else if (params.url) {
|
|
87
|
-
result = await getClient().uploadMedia({
|
|
98
|
+
result = await getClient().uploadMedia({
|
|
99
|
+
url: params.url,
|
|
100
|
+
filename: params.filename,
|
|
101
|
+
name: params.name || params.filename,
|
|
102
|
+
folder: params.folder,
|
|
103
|
+
...(params.pdf_mode ? { pdf_mode: params.pdf_mode } : {}),
|
|
104
|
+
});
|
|
88
105
|
}
|
|
89
106
|
else {
|
|
90
107
|
return {
|
|
@@ -97,6 +114,23 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
97
114
|
};
|
|
98
115
|
}
|
|
99
116
|
const compatibility = result.compatibility;
|
|
117
|
+
// PDF kept as ONE document item (pdf_mode "document").
|
|
118
|
+
if (result.data?.type === "document" && result.pdf) {
|
|
119
|
+
const d = result.data;
|
|
120
|
+
const pdfInfo = result.pdf;
|
|
121
|
+
const pageCount = Array.isArray(d.pdf?.pages) ? d.pdf.pages.length : pdfInfo.rendered_pages;
|
|
122
|
+
let md = `## PDF Uploaded as One Document\n\n`;
|
|
123
|
+
md += `**${d.name || pdfInfo.name || "document.pdf"}**, ${pageCount} ${pageCount === 1 ? "page" : "pages"}`;
|
|
124
|
+
if (pdfInfo.truncated)
|
|
125
|
+
md += ` (${pdfInfo.total_pages} in the file; the first ${pdfInfo.rendered_pages} became page images)`;
|
|
126
|
+
md += `.\n\n**Media ID:** \`${d.id}\`\n\n`;
|
|
127
|
+
md += `Pass this ONE id in \`create_post\` as \`media_ids: ["${d.id}"]\`; the post gets every page, in order. `;
|
|
128
|
+
md += `On LinkedIn it posts as a swipeable **document** made from the **original PDF file** (sharp text, working links, the real download); on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first page.`;
|
|
129
|
+
if (compatibility && compatibility.compatible === false && compatibility.summary) {
|
|
130
|
+
md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before posting.`;
|
|
131
|
+
}
|
|
132
|
+
return { content: [{ type: "text", text: md }] };
|
|
133
|
+
}
|
|
100
134
|
// PDF upload: the server split it into image slides. Surface every slide
|
|
101
135
|
// id and tell the model to pass them ALL to create_post as one carousel.
|
|
102
136
|
const mediaIds = result.media_ids;
|
|
@@ -115,7 +149,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
115
149
|
});
|
|
116
150
|
md += `\n**Pass ALL of these to \`create_post\` as \`media_ids\`, in this order**, to post the deck as a carousel:\n\n`;
|
|
117
151
|
md += `\`\`\`\nmedia_ids: [${mediaIds.map((id) => `"${id}"`).join(", ")}]\n\`\`\`\n\n`;
|
|
118
|
-
md += `On LinkedIn this posts as a swipeable **document** carousel; on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first slide.`;
|
|
152
|
+
md += `On LinkedIn this posts as a swipeable **document** carousel made from the **original PDF file** (kept in storage: sharp text, working links, the real download${pdf?.truncated ? `, all ${pdf.total_pages} pages` : ""}) as long as the slides are posted unchanged and in order; on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first slide. To send a document rebuilt from the slide images instead, set linkedin.document_source to "slides" on the post.`;
|
|
119
153
|
if (compatibility && compatibility.compatible === false && compatibility.summary) {
|
|
120
154
|
md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before posting.`;
|
|
121
155
|
}
|