@omnisocials/mcp-server 1.29.0 → 1.31.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,14 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
218
223
 
219
224
  ## Changelog
220
225
 
226
+ ### 1.31.0 (2026-08-30)
227
+
228
+ - **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`).
229
+
230
+ ### 1.30.0 (2026-08-30)
231
+
232
+ - **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`).
233
+
221
234
  ### 1.29.0 (2026-08-29)
222
235
 
223
236
  - **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;
@@ -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
  }
@@ -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
  }
@@ -370,6 +370,12 @@ export function registerPostTools(server, getClient) {
370
370
  // Instagram extras, echoed top-level by the API to mirror create_post.
371
371
  if (p.location_id)
372
372
  md += `| **IG location** | \`${p.location_id}\` |\n`;
373
+ // Threads location tag read-back (threads.location on the Post).
374
+ const threadsLoc = p.threads?.location;
375
+ if (threadsLoc?.id) {
376
+ const locBits = [threadsLoc.name, threadsLoc.city, threadsLoc.country].filter(Boolean).join(", ");
377
+ md += `| **Threads location** | ${locBits ? `${locBits} ` : ""}\`${threadsLoc.id}\` |\n`;
378
+ }
373
379
  if (Array.isArray(p.collaborators) && p.collaborators.length) {
374
380
  md += `| **IG collaborators** | ${p.collaborators.map((c) => `@${c}`).join(", ")} |\n`;
375
381
  }
@@ -694,8 +700,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
694
700
  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
701
  }).optional().describe("Mastodon options"),
696
702
  threads: z.object({
703
+ 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
704
  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"),
705
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
699
706
  google_business: GOOGLE_BUSINESS_OPTIONS,
700
707
  }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
701
708
  const result = await getClient().createPost({ ...params, source: "mcp" });
@@ -841,8 +848,9 @@ Do NOT call without required media — it will fail.`, {
841
848
  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
849
  }).optional().describe("Mastodon options"),
843
850
  threads: z.object({
851
+ 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
852
  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"),
853
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
846
854
  google_business: GOOGLE_BUSINESS_OPTIONS,
847
855
  }, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
848
856
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
@@ -954,8 +962,9 @@ Do NOT call without required media — it will fail.`, {
954
962
  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
963
  }).optional().describe("Mastodon options"),
956
964
  threads: z.object({
965
+ 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
966
  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"),
967
+ }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
959
968
  google_business: GOOGLE_BUSINESS_OPTIONS,
960
969
  }, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
961
970
  const result = await getClient().updatePost(id, data);
@@ -1049,32 +1058,152 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
1049
1058
  content: [{ type: "text", text: md }],
1050
1059
  };
1051
1060
  });
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.
1061
+ 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.
1062
+
1063
+ 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.
1064
+
1065
+ 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.`, {
1066
+ id: z.string().describe("The post ID to approve (must have approval_status 'pending')"),
1067
+ }, { title: "Approve Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
1068
+ const result = await getClient().approvePost(id);
1069
+ if (result.error) {
1070
+ return {
1071
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1072
+ };
1073
+ }
1074
+ const p = result.data;
1075
+ if (!p) {
1076
+ return {
1077
+ content: [{ type: "text", text: "Approval recorded. Check its status with get_post." }],
1078
+ };
1079
+ }
1080
+ let md = `## Approval Recorded\n\n`;
1081
+ md += `| Field | Value |\n`;
1082
+ md += `|-------|-------|\n`;
1083
+ md += `| **ID** | \`${p.id}\` |\n`;
1084
+ md += `| **Status** | ${capitalize(p.status || "in_approval")} |\n`;
1085
+ md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
1086
+ if (p.message)
1087
+ md += `\n${p.message}\n`;
1088
+ return {
1089
+ content: [{ type: "text", text: md }],
1090
+ };
1091
+ });
1092
+ 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.
1093
+
1094
+ 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.`, {
1095
+ id: z.string().describe("The post ID to reject (must have approval_status 'pending')"),
1096
+ comment: z
1097
+ .string()
1098
+ .max(2000)
1099
+ .optional()
1100
+ .describe("Optional reason for the rejection, shown to the requester and other approvers."),
1101
+ }, { title: "Reject Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id, comment }) => {
1102
+ const result = await getClient().rejectPost(id, comment);
1103
+ if (result.error) {
1104
+ return {
1105
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1106
+ };
1107
+ }
1108
+ const p = result.data;
1109
+ let md = `## Post Rejected\n\n`;
1110
+ md += `| Field | Value |\n`;
1111
+ md += `|-------|-------|\n`;
1112
+ md += `| **ID** | \`${p?.id || id}\` |\n`;
1113
+ md += `| **Status** | Rejected |\n`;
1114
+ md += `\n_The approval workflow has stopped. This post will not be published unless it's edited and resubmitted for approval._`;
1115
+ return {
1116
+ content: [{ type: "text", text: md }],
1117
+ };
1118
+ });
1119
+ 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.
1053
1120
 
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.
1121
+ 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.
1122
+
1123
+ 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.
1124
+ 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
1125
 
1056
1126
  Notes:
1057
1127
  - 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.`, {
1128
+ - 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.
1129
+ - 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
1130
  query: z
1061
1131
  .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);
1132
+ .optional()
1133
+ .describe("Place name to search (min 2 chars): a dealership, café, venue, etc. Required for instagram; for threads pass either query or latitude + longitude."),
1134
+ platform: z
1135
+ .enum(["instagram", "threads"])
1136
+ .optional()
1137
+ .describe("Which platform the location will be tagged on. Defaults to instagram. Use threads to get a Threads location id for threads.location_id."),
1138
+ latitude: z
1139
+ .number()
1140
+ .min(-90)
1141
+ .max(90)
1142
+ .optional()
1143
+ .describe("Threads only. Search places around this point; pass together with longitude instead of query."),
1144
+ longitude: z
1145
+ .number()
1146
+ .min(-180)
1147
+ .max(180)
1148
+ .optional()
1149
+ .describe("Threads only. Pass together with latitude."),
1150
+ }, { title: "Search Locations", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query, platform, latitude, longitude }) => {
1151
+ const target = platform === "threads" ? "threads" : "instagram";
1152
+ if (!query && target !== "threads") {
1153
+ return {
1154
+ content: [
1155
+ {
1156
+ type: "text",
1157
+ text: 'Pass a place name to search (query). Coordinates are only supported with platform "threads".',
1158
+ },
1159
+ ],
1160
+ };
1161
+ }
1162
+ if (target === "threads" && !query && (latitude === undefined || longitude === undefined)) {
1163
+ return {
1164
+ content: [
1165
+ {
1166
+ type: "text",
1167
+ text: "Pass a place name (query), or both latitude and longitude.",
1168
+ },
1169
+ ],
1170
+ };
1171
+ }
1172
+ const result = await getClient().searchLocations(query, target, latitude !== undefined || longitude !== undefined
1173
+ ? { latitude, longitude }
1174
+ : undefined);
1175
+ const label = query ? `"${query}"` : `${latitude}, ${longitude}`;
1176
+ if (target === "threads") {
1177
+ const locations = result?.locations || [];
1178
+ if (!locations.length) {
1179
+ // Threads answers with a { code, message } error object when the
1180
+ // search could not run (not_available, threads_not_connected,
1181
+ // threads_reauth_required, platform_error).
1182
+ const err = result?.error;
1183
+ const why = (typeof err === "string" ? err : err?.message) ||
1184
+ `No Threads locations found for ${label}. Try a more specific place name.`;
1185
+ return { content: [{ type: "text", text: why }] };
1186
+ }
1187
+ let md = `## Threads locations matching ${label}\n\nAsk the user which one, then use its ID as \`threads.location_id\`:\n\n`;
1188
+ md += `| # | Name | Address | City | Country | threads.location_id |\n|---|------|---------|------|---------|---------------------|\n`;
1189
+ locations.forEach((p, i) => {
1190
+ md += `| ${i + 1} | ${p.name || "-"} | ${p.address || "-"} | ${p.city || "-"} | ${p.country || "-"} | \`${p.id}\` |\n`;
1191
+ });
1192
+ return { content: [{ type: "text", text: md }] };
1193
+ }
1065
1194
  const places = result?.data || [];
1066
1195
  if (!places.length) {
1067
1196
  // The degraded path returns `error` as a plain string; the object form
1068
1197
  // only appears on transport failures — render its message.
1069
1198
  const err = result?.error;
1070
1199
  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.`;
1200
+ `No taggable locations found for ${label}. Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
1072
1201
  return { content: [{ type: "text", text: why }] };
1073
1202
  }
1074
- let md = `## Locations matching "${query}"\n\nAsk the user which one, then use its ID as \`location_id\`:\n\n`;
1203
+ let md = `## Instagram locations matching ${label}\n\nAsk the user which one, then use its ID as \`location_id\`:\n\n`;
1075
1204
  md += `| # | Name | Address | location_id |\n|---|------|---------|-------------|\n`;
1076
1205
  places.forEach((p, i) => {
1077
- md += `| ${i + 1} | ${p.name} | ${p.address || ""} | \`${p.id}\` |\n`;
1206
+ md += `| ${i + 1} | ${p.name} | ${p.address || "-"} | \`${p.id}\` |\n`;
1078
1207
  });
1079
1208
  return { content: [{ type: "text", text: md }] };
1080
1209
  });
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.31.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",