@omnisocials/mcp-server 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -115,7 +115,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
115
115
 
116
116
  ## Available Tools
117
117
 
118
- ### Posts (12 tools)
118
+ ### Posts (13 tools)
119
119
 
120
120
  | Tool | Description |
121
121
  |------|-------------|
@@ -127,6 +127,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
127
127
  | `delete_post` | Delete a post |
128
128
  | `publish_post` | Publish a draft or scheduled post now |
129
129
  | `retry_post` | Retry the failed platforms of a failed or partially failed post |
130
+ | `list_approval_workflows` | List the workspace's approval workflows (steps + approvers); pass an id as `approval_workflow_id` on `create_post` to hold the post for review |
130
131
  | `approve_post` | Approve the current step of a post's approval workflow (the connected user must be a listed approver for that step) |
131
132
  | `reject_post` | Reject a post's approval workflow, stopping it immediately |
132
133
  | `search_locations` | Find an Instagram or Threads location to tag (Instagram place IDs for `location_id`, Threads location ids for `threads.location_id`; Threads also searches by latitude/longitude) |
@@ -223,6 +224,10 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
223
224
 
224
225
  ## Changelog
225
226
 
227
+ ### 1.32.0 (2026-09-02)
228
+
229
+ - **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`).
230
+
226
231
  ### 1.31.0 (2026-08-30)
227
232
 
228
233
  - **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`).
package/build/client.d.ts CHANGED
@@ -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
@@ -184,6 +184,27 @@ export interface CreatePostInput {
184
184
  hashtag_set?: string;
185
185
  hashtag_placement?: "caption_append" | "first_comment";
186
186
  hashtag_platforms?: string[];
187
+ /** Route the post through a saved approval workflow (GET /approval-workflows). */
188
+ approval_workflow_id?: string;
189
+ }
190
+ export interface ApprovalWorkflowApprover {
191
+ id: string;
192
+ name: string | null;
193
+ email: string | null;
194
+ }
195
+ export interface ApprovalWorkflowStep {
196
+ order: number;
197
+ name: string;
198
+ require_mode: "any" | "all";
199
+ approvers: ApprovalWorkflowApprover[];
200
+ }
201
+ export interface ApprovalWorkflow {
202
+ id: string;
203
+ name: string;
204
+ workspace_id: number | null;
205
+ steps: ApprovalWorkflowStep[];
206
+ created_at: string;
207
+ updated_at: string;
187
208
  }
188
209
  export declare class OmniSocialsClient {
189
210
  private baseUrl;
@@ -298,6 +319,7 @@ export declare class OmniSocialsClient {
298
319
  name: string;
299
320
  parent_id?: string;
300
321
  }): Promise<ApiResponse<MediaFolder>>;
322
+ listApprovalWorkflows(): Promise<ApiResponse<ApprovalWorkflow[]>>;
301
323
  listHashtagSets(): Promise<ApiResponse<HashtagSet[]>>;
302
324
  createHashtagSet(data: {
303
325
  name: string;
package/build/client.js CHANGED
@@ -350,6 +350,10 @@ export class OmniSocialsClient {
350
350
  async createFolder(data) {
351
351
  return this.request("POST", "/folders", data);
352
352
  }
353
+ // Approval workflows (read-only; configured in the dashboard)
354
+ async listApprovalWorkflows() {
355
+ return this.request("GET", "/approval-workflows");
356
+ }
353
357
  // Hashtag Sets
354
358
  async listHashtagSets() {
355
359
  return this.request("GET", "/hashtag-sets");
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);
@@ -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
  });
@@ -550,6 +555,57 @@ export function registerPostTools(server, getClient) {
550
555
  if (firstCommentRows.length) {
551
556
  md += `\n\n### First Comments\n\n${firstCommentRows.join("\n")}\n`;
552
557
  }
558
+ // Per-platform publish options read-back (TikTok cover frame, privacy,
559
+ // disclosures; YouTube title/privacy/tags; Pinterest board/title/link),
560
+ // so a caller can verify what was set without opening the dashboard.
561
+ // Only values that were actually chosen are listed.
562
+ const optionRows = [];
563
+ const tk = p.tiktok && typeof p.tiktok === "object" ? p.tiktok : {};
564
+ if (typeof tk.title === "string" && tk.title)
565
+ optionRows.push(["TikTok title", tk.title]);
566
+ if (typeof tk.privacy_level === "string" && tk.privacy_level)
567
+ optionRows.push(["TikTok privacy", tk.privacy_level]);
568
+ const coverMs = Number(tk.video_cover_timestamp_ms) || 0;
569
+ if (coverMs > 0)
570
+ optionRows.push(["TikTok cover frame", `${coverMs} ms (${(coverMs / 1000).toFixed(1)}s into the video)`]);
571
+ for (const [key, label] of [
572
+ ["disable_comment", "TikTok comments"],
573
+ ["disable_duet", "TikTok duet"],
574
+ ["disable_stitch", "TikTok stitch"],
575
+ ]) {
576
+ if (tk[key] === true)
577
+ optionRows.push([label, "disabled"]);
578
+ }
579
+ for (const [key, label] of [
580
+ ["is_aigc", "TikTok AI-generated label"],
581
+ ["brand_content_toggle", "TikTok paid partnership"],
582
+ ["brand_organic_toggle", "TikTok own-brand disclosure"],
583
+ ["auto_add_music", "TikTok auto-add music"],
584
+ ]) {
585
+ if (tk[key] === true)
586
+ optionRows.push([label, "on"]);
587
+ }
588
+ const yt = p.youtube && typeof p.youtube === "object" ? p.youtube : {};
589
+ if (typeof yt.title === "string" && yt.title)
590
+ optionRows.push(["YouTube title", yt.title]);
591
+ if (typeof yt.privacy_status === "string" && yt.privacy_status)
592
+ optionRows.push(["YouTube privacy", yt.privacy_status]);
593
+ if (Array.isArray(yt.tags) && yt.tags.length)
594
+ optionRows.push(["YouTube tags", yt.tags.join(", ")]);
595
+ if (yt.made_for_kids === true)
596
+ optionRows.push(["YouTube made for kids", "yes"]);
597
+ const pin = p.pinterest && typeof p.pinterest === "object" ? p.pinterest : {};
598
+ if (typeof pin.board_id === "string" && pin.board_id)
599
+ optionRows.push(["Pinterest board", pin.board_id]);
600
+ if (typeof pin.title === "string" && pin.title)
601
+ optionRows.push(["Pinterest title", pin.title]);
602
+ if (typeof pin.link === "string" && pin.link)
603
+ optionRows.push(["Pinterest link", pin.link]);
604
+ if (optionRows.length) {
605
+ md += `\n\n### Platform Options\n\n| Option | Value |\n|--------|-------|\n`;
606
+ for (const [k, v] of optionRows)
607
+ md += `| ${k} | ${v.replace(/\|/g, "\\|")} |\n`;
608
+ }
553
609
  // LinkedIn multi-image style: nested under each channel's options
554
610
  // object by the API. Only rendered when the post opts out of the
555
611
  // default PDF document carousel.
@@ -615,8 +671,8 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
615
671
  - Pinterest carousel — 2–5 images, all same aspect ratio (a video splits off into its own video pin)
616
672
  - Facebook, LinkedIn, LinkedIn Page — cannot mix images and video in one post (images only, or a single video)
617
673
 
618
- **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."):**
619
- - 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)
674
+ **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."):**
675
+ - 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
620
676
  - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
621
677
  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.
622
678
 
@@ -629,7 +685,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
629
685
  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.
630
686
  - YouTube: Title, privacy status, tags?
631
687
  - TikTok: Privacy level?
632
- - **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.
688
+ - **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.
633
689
  - **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.
634
690
  - **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.
635
691
 
@@ -704,6 +760,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
704
760
  thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread instead of a single post. Provide 2 to 25 parts; parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids (from upload_media) or media_urls. When set, the Threads caption is taken from part 1 and content is ignored for Threads. For a single post, omit thread_parts and use content."),
705
761
  }).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
706
762
  google_business: GOOGLE_BUSINESS_OPTIONS,
763
+ approval_workflow_id: z.string().optional().describe("Route the post through a saved approval workflow (id from list_approval_workflows). The post is created as `in_approval` instead of `scheduled`: its approvers are notified, they review it on the Approvals page, and it publishes at scheduled_at only once the workflow's last step approves it (or approve_post). Use this whenever the user wants to review/approve agent-created posts before they go out. Requires scheduled_at; not allowed with publish_now (use create_post, never create_and_publish_post). A workflow with no approvers on its first step is rejected with validation_error."),
707
764
  }, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
708
765
  const result = await getClient().createPost({ ...params, source: "mcp" });
709
766
  if (result.error) {
@@ -726,18 +783,26 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
726
783
  try {
727
784
  const schedAt = p.schedule_at ?? p.scheduled_at;
728
785
  const channels = selectedChannels(p);
729
- let md = `## Draft Created (not yet published)\n\n`;
786
+ const inApproval = p.status === "in_approval";
787
+ let md = inApproval
788
+ ? `## Post Submitted for Approval\n\n`
789
+ : `## Draft Created (not yet published)\n\n`;
730
790
  md += `| Field | Value |\n`;
731
791
  md += `|-------|-------|\n`;
732
792
  md += `| **ID** | \`${p.id}\` |\n`;
733
793
  md += `| **Status** | ${capitalize(p.status || "draft")} |\n`;
794
+ if (inApproval)
795
+ md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
734
796
  if (schedAt)
735
- md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
797
+ md += `| **${inApproval ? "Publishes (once approved)" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
736
798
  if (channels.length)
737
799
  md += `| **Channels** | ${channels.join(", ")} |\n`;
738
800
  if (appUrl)
739
801
  md += `| **Open in OmniSocials** | ${appUrl} |\n`;
740
802
  md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
803
+ if (inApproval) {
804
+ md += `_The approvers have been notified. The post publishes at the time above as soon as the workflow's last step approves it (Approvals page in OmniSocials, or approve_post). Nothing else is needed from the user until then._\n\n`;
805
+ }
741
806
  if (Array.isArray(result.warnings) && result.warnings.length > 0) {
742
807
  for (const w of result.warnings) {
743
808
  md += `⚠️ ${w.message}\n`;
@@ -779,9 +844,9 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
779
844
  - Pinterest: ALWAYS need an image + board_id.
780
845
  - Ask the user which media to use. Use list_media to show options.
781
846
  - **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.
782
- - **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.
847
+ - **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.
783
848
  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.
784
- 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).
849
+ 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).
785
850
  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.
786
851
 
787
852
  Do NOT call without required media — it will fail.`, {
@@ -900,7 +965,7 @@ Do NOT call without required media — it will fail.`, {
900
965
 
901
966
  **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.
902
967
 
903
- **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).`, {
968
+ **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).`, {
904
969
  id: z.string().describe("The post ID to update"),
905
970
  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\" }."),
906
971
  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."),
@@ -1058,6 +1123,38 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
1058
1123
  content: [{ type: "text", text: md }],
1059
1124
  };
1060
1125
  });
1126
+ server.tool("list_approval_workflows", `List the approval workflows this workspace can use (company-wide ones plus workspace-bound ones), with their steps and named approvers. Workflows are created in the OmniSocials dashboard (Approvals); this tool only lists them.
1127
+
1128
+ Pass a workflow's id as \`approval_workflow_id\` on \`create_post\` to hold a scheduled post for review: it is created as \`in_approval\`, the approvers are notified, and it publishes at \`scheduled_at\` once the last step approves. Call this first whenever the user wants agent-created posts to be reviewed/approved before they go out; if the list is empty, tell the user to create a workflow under Approvals in the dashboard.`, {}, { title: "List Approval Workflows", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
1129
+ const result = await getClient().listApprovalWorkflows();
1130
+ if (result.error) {
1131
+ return {
1132
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
1133
+ };
1134
+ }
1135
+ const items = Array.isArray(result.data) ? result.data : [];
1136
+ if (!items.length) {
1137
+ return {
1138
+ content: [{
1139
+ type: "text",
1140
+ text: "No approval workflows yet. The user can create one in the OmniSocials dashboard under Approvals (choose the approvers per step); then create_post with approval_workflow_id routes posts through it.",
1141
+ }],
1142
+ };
1143
+ }
1144
+ let md = `## Approval Workflows (${items.length})\n\n`;
1145
+ for (const w of items) {
1146
+ md += `### \`${w.id}\` ${w.name}${w.workspace_id ? "" : " _(company-wide)_"}\n\n`;
1147
+ for (const s of w.steps || []) {
1148
+ const approvers = (s.approvers || []).map((a) => a.name || a.email || a.id).join(", ") || "*(no approvers)*";
1149
+ md += `- Step ${s.order}: ${s.name || "Review"} — ${s.require_mode === "all" ? "all must approve" : "any one approves"}: ${approvers}\n`;
1150
+ }
1151
+ md += `\n`;
1152
+ }
1153
+ md += `Use create_post with approval_workflow_id="<id>" (plus scheduled_at) to submit a post for review.`;
1154
+ return {
1155
+ content: [{ type: "text", text: md }],
1156
+ };
1157
+ });
1061
1158
  server.tool("approve_post", `Approve the current step of a post's approval workflow. Only works on a post with \`approval_status: "pending"\` (post status \`in_approval\`) — check \`get_post\` first.
1062
1159
 
1063
1160
  IMPORTANT: this only succeeds if the connected user is a listed approver for the workflow's CURRENT step — steps approve in order, so being an approver on a later step is not enough yet. A \`forbidden\` error means the user isn't (or isn't yet) an approver for this step; tell them who needs to approve instead, don't retry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "mcpName": "io.github.omnisocials/mcp-server",
5
5
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
6
6
  "type": "module",
@@ -53,7 +53,8 @@
53
53
  },
54
54
  "overrides": {
55
55
  "zod": "4.4.0",
56
- "fast-uri": "3.1.1",
57
- "hono": "4.12.18"
56
+ "fast-uri": "^3.1.7",
57
+ "hono": "^4.13.7",
58
+ "qs": "^6.16.0"
58
59
  }
59
60
  }