@omnisocials/mcp-server 1.29.0 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -109,13 +109,13 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
109
109
  | `tiktok` | TikTok | Posts and reels |
110
110
  | `pinterest` | Pinterest | Pins (requires `board_id`) |
111
111
  | `x` | X (Twitter) | Posts, chained threads via `x.thread_parts` |
112
- | `threads` | Threads | Posts, chained threads via `threads.thread_parts` |
112
+ | `threads` | Threads | Posts, chained threads via `threads.thread_parts`, location tagging via `threads.location_id` (rolling out) |
113
113
  | `bluesky` | Bluesky | Posts, chained threads via `bluesky.thread_parts` |
114
114
  | `mastodon` | Mastodon | Posts, chained threads via `mastodon.thread_parts` |
115
115
 
116
116
  ## Available Tools
117
117
 
118
- ### Posts (7 tools)
118
+ ### Posts (12 tools)
119
119
 
120
120
  | Tool | Description |
121
121
  |------|-------------|
@@ -126,7 +126,11 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
126
126
  | `update_post` | Update a draft or scheduled post |
127
127
  | `delete_post` | Delete a post |
128
128
  | `publish_post` | Publish a draft or scheduled post now |
129
- | `search_locations` | Find an Instagram location to tag (returns place IDs for `location_id`) |
129
+ | `retry_post` | Retry the failed platforms of a failed or partially failed post |
130
+ | `approve_post` | Approve the current step of a post's approval workflow (the connected user must be a listed approver for that step) |
131
+ | `reject_post` | Reject a post's approval workflow, stopping it immediately |
132
+ | `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) |
133
+ | `search_instagram_audio` | Search Instagram's licensed music catalog for a track to attach to a Reel |
130
134
 
131
135
  ### Media & folders (6 tools)
132
136
 
@@ -184,14 +188,15 @@ Apply a set when creating a post: `create_post` / `create_and_publish_post` acce
184
188
  | `delete_webhook` | Delete a webhook |
185
189
  | `rotate_webhook_secret` | Rotate a webhook's signing secret |
186
190
 
187
- ### Social Inbox (4 tools)
191
+ ### Social Inbox (5 tools)
188
192
 
189
193
  | Tool | Description |
190
194
  |------|-------------|
191
- | `list_inbox_conversations` | List conversations — Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, and X DMs (opt-in); filter by platform, type, or unread |
195
+ | `list_inbox_conversations` | List conversations — Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs (opt-in), and Threads replies on your posts plus mentions (rolling out); filter by platform, type, or unread |
192
196
  | `get_inbox_conversation` | Get a conversation's full message history |
193
197
  | `mark_inbox_read` | Mark all incoming messages in a conversation as read |
194
198
  | `reply_to_inbox` | Reply to a DM, comment, or mention (existing conversations only) |
199
+ | `hide_inbox_reply` | Hide or unhide a reply on one of your Threads posts (Threads only for now) |
195
200
 
196
201
  Requires an API key with the opt-in `inbox:read` / `inbox:write` scopes — enable "Social Inbox access" when you create the key.
197
202
 
@@ -218,6 +223,18 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
218
223
 
219
224
  ## Changelog
220
225
 
226
+ ### 1.32.0 (2026-09-02)
227
+
228
+ - **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`).
229
+
230
+ ### 1.31.0 (2026-08-30)
231
+
232
+ - **Added: write access to approvals.** New tools `approve_post` and `reject_post`. Both act on a post's approval workflow (`approval_status: "pending"`, post status `in_approval`) and only succeed if the connected user is a listed approver for the workflow's CURRENT step — steps approve in order. Approving the final step finalizes the post (`scheduled` or `posting`); rejecting stops the whole workflow immediately (`rejected`), with an optional `comment` shown to the requester. Previously `approval_status` was read-only via `get_post` / `list_posts`. Same on the companion server (`mcp.omnisocials.com`).
233
+
234
+ ### 1.30.0 (2026-08-30)
235
+
236
+ - **Added: media on inbox messages.** Instagram and Facebook DM messages (images, videos, voice messages, and story mentions) now carry an `attachment` object (`url`, `type`) in `get_inbox_conversation`. Incoming attachment URLs are re-hosted on our own CDN so they stay valid indefinitely. `reply_to_inbox` gains optional `attachment_url` / `attachment_type` to send media on a Facebook or Instagram DM reply; `text` is optional when an attachment is set. Same on the companion server (`mcp.omnisocials.com`).
237
+
221
238
  ### 1.29.0 (2026-08-29)
222
239
 
223
240
  - **Added: YouTube comments in the Social Inbox.** `list_inbox_conversations` accepts `platform: "youtube"` and returns YouTube video comment threads (comments only; YouTube has no DMs). `reply_to_inbox` can reply to YouTube comments. YouTube has no comment webhooks, so each channel is checked for new comments once per day; a new comment can take up to a day to appear. 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, 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, InboxMessage, Webhook } from "./types.js";
2
2
  export declare function fetchImageAsBase64(url: string): Promise<{
3
3
  data: string;
4
4
  mimeType: string;
@@ -34,7 +34,7 @@ export type MediaIdEntry = string | {
34
34
  alt?: string;
35
35
  };
36
36
  export interface XThreadPartInput {
37
- /** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
37
+ /** Tweet text — ≤ 280 chars standard, 25,000 for X Premium/Premium+ accounts. */
38
38
  text: string;
39
39
  /** Optional per-part media as Library IDs from upload_media (max 4 combined
40
40
  * with media_urls). Entries may be `{ id, alt }` to attach alt text (X
@@ -117,6 +117,11 @@ export interface ThreadsPostOptions {
117
117
  /** Provide 2 to 25 parts to publish as a thread. Omit for a single post.
118
118
  * When set, the Threads caption is taken from part 1. */
119
119
  thread_parts?: ThreadsThreadPartInput[];
120
+ /** Threads location id from search_locations with platform "threads"
121
+ * (NOT a Facebook Place ID). Tagged on the post; on a multi-post thread
122
+ * the tag goes on the first post. Needs the threads_location_tagging
123
+ * permission on the Threads connection. */
124
+ location_id?: string | null;
120
125
  }
121
126
  /**
122
127
  * Update-side variant: `thread_parts: null` clears the thread (revert to a
@@ -124,6 +129,9 @@ export interface ThreadsPostOptions {
124
129
  */
125
130
  export interface ThreadsPostOptionsUpdate {
126
131
  thread_parts?: ThreadsThreadPartInput[] | null;
132
+ /** New Threads location id, or null to remove the tag. Omit to leave the
133
+ * existing tag untouched. */
134
+ location_id?: string | null;
127
135
  }
128
136
  /** Non-sponsored LinkedIn poll: question + 2-4 options + duration. */
129
137
  export interface LinkedInPollFields {
@@ -236,7 +244,12 @@ export declare class OmniSocialsClient {
236
244
  deletePost(id: string): Promise<ApiResponse<unknown>>;
237
245
  publishPost(id: string): Promise<ApiResponse<PublishPostResult>>;
238
246
  retryPost(id: string): Promise<ApiResponse<RetryPostResult>>;
239
- searchLocations(query: string): Promise<LocationSearchResult>;
247
+ approvePost(id: string): Promise<ApiResponse<ApprovePostResult>>;
248
+ rejectPost(id: string, comment?: string): Promise<ApiResponse<RejectPostResult>>;
249
+ searchLocations(query?: string, platform?: "instagram" | "threads", coords?: {
250
+ latitude?: number;
251
+ longitude?: number;
252
+ }): Promise<LocationSearchResult>;
240
253
  validateLocation(id: string): Promise<ApiResponse<unknown>>;
241
254
  searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<AudioSearchResult>;
242
255
  listMedia(params?: {
@@ -358,4 +371,5 @@ export declare class OmniSocialsClient {
358
371
  attachment_url?: string;
359
372
  attachment_type?: string;
360
373
  }): Promise<ApiResponse<InboxMessage>>;
374
+ hideInboxMessage(messageId: string, hide?: boolean): Promise<ApiResponse<InboxMessage>>;
361
375
  }
package/build/client.js CHANGED
@@ -272,13 +272,34 @@ export class OmniSocialsClient {
272
272
  async retryPost(id) {
273
273
  return this.request("POST", `/posts/${id}/retry`);
274
274
  }
275
- // Locations (Instagram place tagging). NOT the standard envelope — see
276
- // LocationSearchResult (degraded-path `error` is a plain string).
277
- async searchLocations(query) {
278
- return this.request("GET", "/locations/search", undefined, {
279
- q: query,
275
+ // Approves the CURRENT step of the post's approval workflow. The API key's
276
+ // owner must be a listed approver for that step (steps approve in order) —
277
+ // otherwise the request returns a `forbidden` error.
278
+ async approvePost(id) {
279
+ return this.request("POST", `/posts/${id}/approve`);
280
+ }
281
+ // Rejects the post's approval workflow, stopping it immediately (not just
282
+ // the current step). Same approver requirement as approvePost.
283
+ async rejectPost(id, comment) {
284
+ return this.request("POST", `/posts/${id}/reject`, {
285
+ ...(comment ? { comment } : {}),
280
286
  });
281
287
  }
288
+ // Locations (Instagram or Threads place tagging). NOT the standard
289
+ // envelope; see LocationSearchResult: Instagram's degraded-path `error` is
290
+ // a plain string next to `data`; Threads answers with `locations` and a
291
+ // { code, message } `error` object when the search could not run.
292
+ // Threads accepts either a text query or a latitude/longitude pair.
293
+ async searchLocations(query, platform = "instagram", coords) {
294
+ const params = { platform };
295
+ if (query)
296
+ params.q = query;
297
+ if (coords?.latitude !== undefined)
298
+ params.latitude = String(coords.latitude);
299
+ if (coords?.longitude !== undefined)
300
+ params.longitude = String(coords.longitude);
301
+ return this.request("GET", "/locations/search", undefined, params);
302
+ }
282
303
  async validateLocation(id) {
283
304
  return this.request("GET", "/locations/validate", undefined, { id });
284
305
  }
@@ -413,4 +434,9 @@ export class OmniSocialsClient {
413
434
  async replyToInboxConversation(conversationId, data) {
414
435
  return this.request("POST", `/inbox/conversations/${encodeURIComponent(conversationId)}/reply`, data);
415
436
  }
437
+ // Hide/unhide a reply on one of the workspace's Threads posts (Threads
438
+ // only for now). `hide: false` unhides. Needs the inbox:write scope.
439
+ async hideInboxMessage(messageId, hide = true) {
440
+ return this.request("POST", `/inbox/messages/${encodeURIComponent(messageId)}/hide`, { hide });
441
+ }
416
442
  }
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.29.0",
32
+ version: "1.32.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);
@@ -7,11 +7,12 @@ const PLATFORM_EMOJI = {
7
7
  tiktok: "🎵",
8
8
  youtube: "▶️",
9
9
  x: "🐦",
10
+ threads: "🧵",
10
11
  };
11
12
  export function registerInboxTools(server, getClient) {
12
- 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, and X DMs where the workspace has opted into X DMs), newest activity first. Cursor-paginated: pass the returned cursor to get the next page.", {
13
+ server.tool("list_inbox_conversations", "List social inbox conversations (Instagram/Facebook DMs, comments, mentions, LinkedIn company-page comments/mentions, TikTok video comments, YouTube video comments, X DMs where the workspace has opted into X DMs, and Threads replies on the workspace's posts plus mentions), newest activity first. Threads conversations are comment (replies on the workspace's posts) and mention only, need a Threads connection with the reply-reading permission, and are currently rolling out (disabled on production until Meta approves the permission). Cursor-paginated: pass the returned cursor to get the next page.", {
13
14
  platform: z
14
- .enum(["instagram", "facebook", "linkedin", "tiktok", "youtube", "x"])
15
+ .enum(["instagram", "facebook", "linkedin", "tiktok", "x", "youtube", "threads"])
15
16
  .optional()
16
17
  .describe("Filter to one platform"),
17
18
  type: z
@@ -109,7 +110,17 @@ export function registerInboxTools(server, getClient) {
109
110
  const who = m.direction === "outgoing"
110
111
  ? "→ You"
111
112
  : `← ${m.sender?.name || m.sender?.username || "Them"}`;
112
- md += `**${who}** _(${formatDateTime(m.timestamp)})_\n${m.text || ""}\n\n`;
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`;
113
124
  }
114
125
  if (result.pagination?.next_cursor) {
115
126
  md += `_More messages — call again with cursor "${result.pagination.next_cursor}"._`;
@@ -139,14 +150,25 @@ export function registerInboxTools(server, getClient) {
139
150
  ],
140
151
  };
141
152
  });
142
- server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, YouTube, or X. You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost) — a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day.", {
153
+ server.tool("reply_to_inbox", "Reply to an existing inbox conversation (DM, comment, or mention) on Instagram, Facebook, LinkedIn, TikTok, X, YouTube, or Threads (Threads replies are on the workspace's posts and mentions; a 401 reauth_required means the Threads connection must be reconnected to grant the reply permission; the reply publishes as a native Threads reply). You can only reply to conversations that already exist. Meta direct-message replies must be within the platform's 24-hour messaging window. TikTok replies are comments only, text-only, and capped at 150 characters; they can take a few minutes to appear on TikTok while they pass spam review. YouTube replies are comments only. X replies are DM-only and use 2 prepaid credits per send (X's API fee passed through at cost) — a 402 insufficient_credits error means the organisation needs to top up at https://app.omnisocials.com/credits; relay that link to the user rather than retrying. Each workspace can send up to 1,000 replies per day. attachment_url/attachment_type let you send media on a Facebook or Instagram DM (text is optional when an attachment is set — an attachment-only DM is allowed); other platforms are text-only.", {
143
154
  conversation_id: z.string().describe("The conversation ID to reply to"),
144
155
  text: z
145
156
  .string()
146
- .describe("The reply text (max 2000 characters; TikTok comments max 150)"),
147
- }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ conversation_id, text }) => {
157
+ .optional()
158
+ .describe("The reply text (max 2000 characters; TikTok comments max 150). Optional when attachment_url is set."),
159
+ attachment_url: z
160
+ .string()
161
+ .optional()
162
+ .describe("Media URL to send (Facebook and Instagram DMs only)"),
163
+ attachment_type: z
164
+ .enum(["image", "video", "audio", "file"])
165
+ .optional()
166
+ .describe("Required if attachment_url is set"),
167
+ }, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ conversation_id, text, attachment_url, attachment_type }) => {
148
168
  const result = await getClient().replyToInboxConversation(conversation_id, {
149
- text,
169
+ text: text ?? "",
170
+ attachment_url,
171
+ attachment_type,
150
172
  });
151
173
  if (result.error) {
152
174
  return {
@@ -168,4 +190,36 @@ export function registerInboxTools(server, getClient) {
168
190
  ],
169
191
  };
170
192
  });
193
+ server.tool("hide_inbox_reply", "Hide (or unhide) a reply someone left on one of the workspace's Threads posts, as the post owner. Threads only for now, and only incoming top-level replies can be hidden (Threads does not allow hiding nested replies). The message keeps its place in the conversation; its `hidden` flag flips. Use the message id shown by get_inbox_conversation. Errors: 400 unsupported_platform (not an incoming Threads reply), 400 not_hideable (nested reply, or Threads refused), 401 reauth_required (the Threads connection lacks the reply permission; reconnect Threads under Settings → Organisation → Workspaces). Threads inbox features are currently rolling out (disabled on production until Meta approves the permission). Requires the inbox:write scope.", {
194
+ message_id: z
195
+ .string()
196
+ .describe("The inbox message id of the reply (from get_inbox_conversation)"),
197
+ hide: z
198
+ .boolean()
199
+ .optional()
200
+ .describe("true (default) hides the reply; false unhides it"),
201
+ }, async ({ message_id, hide }) => {
202
+ const result = await getClient().hideInboxMessage(message_id, hide ?? true);
203
+ if (result.error) {
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text",
208
+ text: `Error (${result.error.code}): ${result.error.message}`,
209
+ },
210
+ ],
211
+ };
212
+ }
213
+ const hidden = result.data?.hidden === true;
214
+ return {
215
+ content: [
216
+ {
217
+ type: "text",
218
+ text: hidden
219
+ ? `Reply \`${message_id}\` is now hidden on Threads.`
220
+ : `Reply \`${message_id}\` is now visible on Threads again.`,
221
+ },
222
+ ],
223
+ };
224
+ });
171
225
  }
@@ -122,6 +122,11 @@ const LINKEDIN_POLL_OPTIONS = z
122
122
  // google_business, and the thread_parts wrappers with their per-tool
123
123
  // nullability/descriptions) stay inline in each tool.
124
124
  const TIKTOK_OPTIONS = z.object({
125
+ title: z
126
+ .string()
127
+ .max(90)
128
+ .optional()
129
+ .describe("Photo carousels only. TikTok post title (max 90 characters), shown above the caption on Photo Mode posts. TikTok's photo endpoint takes the title and the caption as two separate fields; the caption (content) is the description. Ignored on video posts, which have one caption field. Read back as tiktok.title in get_post. The tiktok block replaces wholesale on update_post, so resend it together with the other TikTok options."),
125
130
  privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
126
131
  disable_comment: z.boolean().optional(),
127
132
  disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
@@ -156,7 +161,7 @@ const USER_TAG_ENTRY = z.object({
156
161
  // z.array(...).min(2).max(25)[.nullable()].optional().describe(...) stays
157
162
  // per-tool (update_post allows null to clear the thread).
158
163
  const X_THREAD_PART = z.object({
159
- text: z.string().describe("Tweet text (≤ 280 chars by X's weighted count). X counts every link as 23 characters (its t.co length) and every emoji or non-Latin symbol (🚀, …, ₹, CJK) as 2 — so text that looks under 280 can still be over. When trimming generated copy to fit, leave headroom instead of cutting to exactly 280 raw characters."),
164
+ text: z.string().describe("Tweet text (≤ 280 chars by X's weighted count; 25,000 for X Premium/Premium+ accounts). X counts every link as 23 characters (its t.co length) and every emoji or non-Latin symbol (🚀, …, ₹, CJK) as 2 — so text that looks under 280 can still be over. When trimming generated copy to fit, leave headroom instead of cutting to exactly 280 raw characters."),
160
165
  media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
161
166
  media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
162
167
  });
@@ -370,6 +375,12 @@ export function registerPostTools(server, getClient) {
370
375
  // Instagram extras, echoed top-level by the API to mirror create_post.
371
376
  if (p.location_id)
372
377
  md += `| **IG location** | \`${p.location_id}\` |\n`;
378
+ // Threads location tag read-back (threads.location on the Post).
379
+ const threadsLoc = p.threads?.location;
380
+ if (threadsLoc?.id) {
381
+ const locBits = [threadsLoc.name, threadsLoc.city, threadsLoc.country].filter(Boolean).join(", ");
382
+ md += `| **Threads location** | ${locBits ? `${locBits} ` : ""}\`${threadsLoc.id}\` |\n`;
383
+ }
373
384
  if (Array.isArray(p.collaborators) && p.collaborators.length) {
374
385
  md += `| **IG collaborators** | ${p.collaborators.map((c) => `@${c}`).join(", ")} |\n`;
375
386
  }
@@ -609,8 +620,8 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
609
620
  - Pinterest carousel — 2–5 images, all same aspect ratio (a video splits off into its own video pin)
610
621
  - Facebook, LinkedIn, LinkedIn Page — cannot mix images and video in one post (images only, or a single video)
611
622
 
612
- **Per-platform video caps (duration + size, checked via ffprobe at submit — exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s — yours is 2min 35s. Trim the video or deselect X."):**
613
- - X — **140s (2min 20s)** · **512MB** (X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images)
623
+ **Per-platform video caps (duration + size, checked via ffprobe at submit; exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s; yours is 2min 35s. Trim the video or deselect X."):**
624
+ - X — **140s (2min 20s)** · **512MB** on free/Basic accounts; when the connected X account has **Premium or Premium+** the caps rise to **125min / 16GB** automatically (tier is read from the connection; reconnect X after upgrading). X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images
614
625
  - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
615
626
  Pick a file within the cap up front; if the user's video is over, tell them the limit so they can trim it rather than retrying blindly.
616
627
 
@@ -623,7 +634,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
623
634
  If the user named a board ("post to my Marketing board") or specified one in the request, use that one instead of the first — match on name (case-insensitive) against the boards list.
624
635
  - YouTube: Title, privacy status, tags?
625
636
  - TikTok: Privacy level?
626
- - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
637
+ - **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
627
638
  - **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
628
639
  - **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
629
640
 
@@ -694,8 +705,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
694
705
  thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
695
706
  }).optional().describe("Mastodon options"),
696
707
  threads: z.object({
708
+ location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (disabled in this environment). On update_post, pass null to remove the tag."),
697
709
  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."),
698
- }).optional().describe("Threads (Meta) options"),
710
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
699
711
  google_business: GOOGLE_BUSINESS_OPTIONS,
700
712
  }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
701
713
  const result = await getClient().createPost({ ...params, source: "mcp" });
@@ -772,9 +784,9 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
772
784
  - Pinterest: ALWAYS need an image + board_id.
773
785
  - Ask the user which media to use. Use list_media to show options.
774
786
  - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
775
- - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** (no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
787
+ - **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
776
788
  4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
777
- 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
789
+ 5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
778
790
  6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
779
791
 
780
792
  Do NOT call without required media — it will fail.`, {
@@ -841,8 +853,9 @@ Do NOT call without required media — it will fail.`, {
841
853
  thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
842
854
  }).optional().describe("Mastodon options"),
843
855
  threads: z.object({
856
+ location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (disabled in this environment). On update_post, pass null to remove the tag."),
844
857
  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."),
845
- }).optional().describe("Threads (Meta) options"),
858
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
846
859
  google_business: GOOGLE_BUSINESS_OPTIONS,
847
860
  }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
848
861
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
@@ -892,7 +905,7 @@ Do NOT call without required media — it will fail.`, {
892
905
 
893
906
  **Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
894
907
 
895
- **X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
908
+ **X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free/Basic, 25,000 on Premium/Premium+). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
896
909
  id: z.string().describe("The post ID to update"),
897
910
  content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post caption / body text. For YouTube Shorts this becomes the video description, NOT the title — to rename the Short, use `youtube.title`. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
898
911
  scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled. A post waiting for approval keeps its in_approval status; only its time moves."),
@@ -954,8 +967,9 @@ Do NOT call without required media — it will fail.`, {
954
967
  thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
955
968
  }).optional().describe("Mastodon options"),
956
969
  threads: z.object({
970
+ location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (disabled in this environment). On update_post, pass null to remove the tag."),
957
971
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Threads (Meta) thread shape on this post. Pass an array (2 to 25 parts, 500 characters per part) to update/create the thread (attach up to 10 media to any part via media_ids or media_urls; images and videos can be mixed), or `null` to revert to single-post mode."),
958
- }).optional().describe("Threads (Meta) options"),
972
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
959
973
  google_business: GOOGLE_BUSINESS_OPTIONS,
960
974
  }, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
961
975
  const result = await getClient().updatePost(id, data);
@@ -1049,32 +1063,152 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
1049
1063
  content: [{ type: "text", text: md }],
1050
1064
  };
1051
1065
  });
1052
- server.tool("search_locations", `Search for an Instagram location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
1066
+ 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
+
1068
+ 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
+
1070
+ 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
+ id: z.string().describe("The post ID to approve (must have approval_status 'pending')"),
1072
+ }, { title: "Approve Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
1073
+ const result = await getClient().approvePost(id);
1074
+ if (result.error) {
1075
+ return {
1076
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1077
+ };
1078
+ }
1079
+ const p = result.data;
1080
+ if (!p) {
1081
+ return {
1082
+ content: [{ type: "text", text: "Approval recorded. Check its status with get_post." }],
1083
+ };
1084
+ }
1085
+ let md = `## Approval Recorded\n\n`;
1086
+ md += `| Field | Value |\n`;
1087
+ md += `|-------|-------|\n`;
1088
+ md += `| **ID** | \`${p.id}\` |\n`;
1089
+ md += `| **Status** | ${capitalize(p.status || "in_approval")} |\n`;
1090
+ md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
1091
+ if (p.message)
1092
+ md += `\n${p.message}\n`;
1093
+ return {
1094
+ content: [{ type: "text", text: md }],
1095
+ };
1096
+ });
1097
+ server.tool("reject_post", `Reject a post's approval workflow. Only works on a post with \`approval_status: "pending"\` (post status \`in_approval\`) — check \`get_post\` first.
1098
+
1099
+ IMPORTANT: this only succeeds if the connected user is a listed approver for the workflow's CURRENT step (same requirement as \`approve_post\`). Unlike approval, a rejection stops the WHOLE workflow immediately, not just the current step — the post's status becomes \`rejected\` and it will not publish. Pass \`comment\` to explain why; it's shown to the requester and other approvers in the post's review thread.`, {
1100
+ id: z.string().describe("The post ID to reject (must have approval_status 'pending')"),
1101
+ comment: z
1102
+ .string()
1103
+ .max(2000)
1104
+ .optional()
1105
+ .describe("Optional reason for the rejection, shown to the requester and other approvers."),
1106
+ }, { title: "Reject Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id, comment }) => {
1107
+ const result = await getClient().rejectPost(id, comment);
1108
+ if (result.error) {
1109
+ return {
1110
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1111
+ };
1112
+ }
1113
+ const p = result.data;
1114
+ let md = `## Post Rejected\n\n`;
1115
+ md += `| Field | Value |\n`;
1116
+ md += `|-------|-------|\n`;
1117
+ md += `| **ID** | \`${p?.id || id}\` |\n`;
1118
+ md += `| **Status** | Rejected |\n`;
1119
+ md += `\n_The approval workflow has stopped. This post will not be published unless it's edited and resubmitted for approval._`;
1120
+ return {
1121
+ content: [{ type: "text", text: md }],
1122
+ };
1123
+ });
1124
+ server.tool("search_locations", `Search for a location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
1125
+
1126
+ Pick the platform with \`platform\`: "instagram" (default) or "threads". The two use DIFFERENT ids (a Facebook Place ID is not a Threads location id), so search with the platform you will tag. If the user wants both, search twice.
1053
1127
 
1054
- Flow: call this with the place name → present the options to the user → once they pick, pass that result's \`id\` as \`location_id\` on create_post / create_and_publish_post / update_post. Only Instagram supports location tagging.
1128
+ Flow (Instagram): call with the place name → present the options to the user → once they pick, pass that result's \`id\` as \`location_id\` on create_post / create_and_publish_post / update_post.
1129
+ Flow (Threads): same, but pass the \`id\` as \`threads.location_id\`. Threads can also search by coordinates: pass latitude + longitude instead of a query. On a multi-post thread the tag goes on the first post.
1055
1130
 
1056
1131
  Notes:
1057
1132
  - Use a SPECIFIC venue name. Searching a broad brand ("Starbucks") returns individual store locations, not the brand page.
1058
- - If the user already gives you a numeric Facebook Place ID, you can pass it straight to create_post as \`location_id\`; it's validated at publish time and an invalid one returns a clear error.
1059
- - If results come back empty with a permission note, the workspace's Facebook app can't search arbitrary places — tell the user to use their own business Page's ID.`, {
1133
+ - Instagram: if the user already gives you a numeric Facebook Place ID, you can pass it straight to create_post as \`location_id\`; it's validated at publish time and an invalid one returns a clear error. If results come back empty with a permission note, the workspace's Facebook app can't search arbitrary places; tell the user to use their own business Page's ID.
1134
+ - Threads: needs the workspace's Threads connection to have the location permission; if the result says to reconnect Threads, tell the user to reconnect it under Settings → Organisation → Workspaces. Threads location tagging is currently rolling out, so the search can also answer that it is not available yet.`, {
1060
1135
  query: z
1061
1136
  .string()
1062
- .describe("Place name to search (min 2 chars) — a dealership, café, venue, etc."),
1063
- }, { title: "Search Locations", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query }) => {
1064
- const result = await getClient().searchLocations(query);
1137
+ .optional()
1138
+ .describe("Place name to search (min 2 chars): a dealership, café, venue, etc. Required for instagram; for threads pass either query or latitude + longitude."),
1139
+ platform: z
1140
+ .enum(["instagram", "threads"])
1141
+ .optional()
1142
+ .describe("Which platform the location will be tagged on. Defaults to instagram. Use threads to get a Threads location id for threads.location_id."),
1143
+ latitude: z
1144
+ .number()
1145
+ .min(-90)
1146
+ .max(90)
1147
+ .optional()
1148
+ .describe("Threads only. Search places around this point; pass together with longitude instead of query."),
1149
+ longitude: z
1150
+ .number()
1151
+ .min(-180)
1152
+ .max(180)
1153
+ .optional()
1154
+ .describe("Threads only. Pass together with latitude."),
1155
+ }, { title: "Search Locations", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query, platform, latitude, longitude }) => {
1156
+ const target = platform === "threads" ? "threads" : "instagram";
1157
+ if (!query && target !== "threads") {
1158
+ return {
1159
+ content: [
1160
+ {
1161
+ type: "text",
1162
+ text: 'Pass a place name to search (query). Coordinates are only supported with platform "threads".',
1163
+ },
1164
+ ],
1165
+ };
1166
+ }
1167
+ if (target === "threads" && !query && (latitude === undefined || longitude === undefined)) {
1168
+ return {
1169
+ content: [
1170
+ {
1171
+ type: "text",
1172
+ text: "Pass a place name (query), or both latitude and longitude.",
1173
+ },
1174
+ ],
1175
+ };
1176
+ }
1177
+ const result = await getClient().searchLocations(query, target, latitude !== undefined || longitude !== undefined
1178
+ ? { latitude, longitude }
1179
+ : undefined);
1180
+ const label = query ? `"${query}"` : `${latitude}, ${longitude}`;
1181
+ if (target === "threads") {
1182
+ const locations = result?.locations || [];
1183
+ if (!locations.length) {
1184
+ // Threads answers with a { code, message } error object when the
1185
+ // search could not run (not_available, threads_not_connected,
1186
+ // threads_reauth_required, platform_error).
1187
+ const err = result?.error;
1188
+ const why = (typeof err === "string" ? err : err?.message) ||
1189
+ `No Threads locations found for ${label}. Try a more specific place name.`;
1190
+ return { content: [{ type: "text", text: why }] };
1191
+ }
1192
+ let md = `## Threads locations matching ${label}\n\nAsk the user which one, then use its ID as \`threads.location_id\`:\n\n`;
1193
+ md += `| # | Name | Address | City | Country | threads.location_id |\n|---|------|---------|------|---------|---------------------|\n`;
1194
+ locations.forEach((p, i) => {
1195
+ md += `| ${i + 1} | ${p.name || "-"} | ${p.address || "-"} | ${p.city || "-"} | ${p.country || "-"} | \`${p.id}\` |\n`;
1196
+ });
1197
+ return { content: [{ type: "text", text: md }] };
1198
+ }
1065
1199
  const places = result?.data || [];
1066
1200
  if (!places.length) {
1067
1201
  // The degraded path returns `error` as a plain string; the object form
1068
1202
  // only appears on transport failures — render its message.
1069
1203
  const err = result?.error;
1070
1204
  const why = (typeof err === "string" ? err : err?.message) ||
1071
- `No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
1205
+ `No taggable locations found for ${label}. Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
1072
1206
  return { content: [{ type: "text", text: why }] };
1073
1207
  }
1074
- let md = `## Locations matching "${query}"\n\nAsk the user which one, then use its ID as \`location_id\`:\n\n`;
1208
+ let md = `## Instagram locations matching ${label}\n\nAsk the user which one, then use its ID as \`location_id\`:\n\n`;
1075
1209
  md += `| # | Name | Address | location_id |\n|---|------|---------|-------------|\n`;
1076
1210
  places.forEach((p, i) => {
1077
- md += `| ${i + 1} | ${p.name} | ${p.address || "—"} | \`${p.id}\` |\n`;
1211
+ md += `| ${i + 1} | ${p.name} | ${p.address || "-"} | \`${p.id}\` |\n`;
1078
1212
  });
1079
1213
  return { content: [{ type: "text", text: md }] };
1080
1214
  });
package/build/types.d.ts CHANGED
@@ -28,7 +28,7 @@ export interface InboxParticipant {
28
28
  }
29
29
  export interface InboxConversation {
30
30
  conversation_id: string;
31
- platform: "instagram" | "facebook" | "linkedin" | "tiktok" | "youtube" | "x";
31
+ platform: "instagram" | "facebook" | "linkedin" | "tiktok" | "x" | "youtube" | "threads";
32
32
  type: "dm" | "comment" | "mention";
33
33
  participant: InboxParticipant;
34
34
  unread_count: number;
@@ -57,6 +57,18 @@ export interface InboxMessage {
57
57
  is_replied: boolean;
58
58
  reaction: string | null;
59
59
  parent_comment_id: string | null;
60
+ /** Threads replies only: true when the reply is hidden on Threads (flip it
61
+ * with hide_inbox_reply). null for every other platform. */
62
+ hidden?: boolean | null;
63
+ /** Link to the reply or mentioning post on the platform, when known. */
64
+ permalink?: string | null;
65
+ /** Media sent with this message, when present. Incoming Instagram/Facebook
66
+ * DM images, videos, voice messages, and story mentions are re-hosted on
67
+ * our CDN so the URL stays valid indefinitely. */
68
+ attachment?: {
69
+ url: string;
70
+ type: "image" | "video" | "audio" | "file";
71
+ } | null;
60
72
  sender: InboxParticipant;
61
73
  post: {
62
74
  id: string | null;
@@ -207,6 +219,21 @@ export interface RetryPostResult {
207
219
  platforms?: string[];
208
220
  message?: string;
209
221
  }
222
+ /** `data` of POST /posts/:id/approve (backend/routes/api/v1/posts.js). */
223
+ export interface ApprovePostResult {
224
+ id: string;
225
+ /** The post's status after this call: `in_approval` (more steps remain), `scheduled`, or `posting`. */
226
+ status: string;
227
+ approval_status: "pending" | "approved";
228
+ message?: string;
229
+ }
230
+ /** `data` of POST /posts/:id/reject (backend/routes/api/v1/posts.js). */
231
+ export interface RejectPostResult {
232
+ id: string;
233
+ status: "rejected";
234
+ approval_status: "rejected";
235
+ message?: string;
236
+ }
210
237
  /** One place from GET /locations/search
211
238
  * (@openapi block in backend/routes/api/v1/locations.js). */
212
239
  export interface LocationPlace {
@@ -217,12 +244,30 @@ export interface LocationPlace {
217
244
  city?: string | null;
218
245
  country?: string | null;
219
246
  }
220
- /** GET /locations/search envelope — NOT the standard data/error envelope:
221
- * on the documented degraded path (no Facebook account / missing permission)
222
- * `error` is a human-readable STRING next to an empty `data`; the standard
223
- * { code, message } object appears only on transport/HTTP failures. */
247
+ /** One place from GET /locations/search?platform=threads. Threads location
248
+ * ids are NOT Facebook Place IDs; use them as `threads.location_id`. */
249
+ export interface ThreadsLocationPlace {
250
+ /** Threads location id: use as `threads.location_id`. */
251
+ id: string;
252
+ name?: string | null;
253
+ address?: string | null;
254
+ city?: string | null;
255
+ country?: string | null;
256
+ latitude?: number | null;
257
+ longitude?: number | null;
258
+ }
259
+ /** GET /locations/search envelope: NOT the standard data/error envelope.
260
+ * platform=instagram (default): `data` holds LocationPlace rows; on the
261
+ * documented degraded path (no Facebook account / missing permission)
262
+ * `error` is a human-readable STRING next to an empty `data`.
263
+ * platform=threads: `locations` holds ThreadsLocationPlace rows, and when
264
+ * the search could not run `error` is a { code, message } OBJECT with code
265
+ * one of not_available | threads_not_connected | threads_reauth_required |
266
+ * platform_error. The standard { code, message } object also appears on
267
+ * transport/HTTP failures. */
224
268
  export interface LocationSearchResult {
225
269
  data?: LocationPlace[];
270
+ locations?: ThreadsLocationPlace[];
226
271
  error?: string | {
227
272
  code: string;
228
273
  message: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.29.0",
3
+ "version": "1.32.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",