@omnisocials/mcp-server 1.3.0 → 1.3.2

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/build/client.d.ts CHANGED
@@ -7,8 +7,29 @@ export declare function formatNumber(n: number): string;
7
7
  export declare function formatBytes(bytes: number): string;
8
8
  export declare function formatDate(iso: string | null): string;
9
9
  export declare function formatDateTime(iso: string | null): string;
10
- export declare function truncate(str: string, len: number): string;
10
+ export declare function truncate(value: unknown, len: number): string;
11
11
  export declare function capitalize(str: string): string;
12
+ export interface XThreadPartInput {
13
+ /** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
14
+ text: string;
15
+ /** Optional per-part media URLs (max 4). Overrides top-level media_urls.x. */
16
+ media_urls?: string[];
17
+ }
18
+ export interface XPostOptions {
19
+ reply_settings?: "" | "following" | "mentionedUsers";
20
+ paid_partnership?: boolean;
21
+ made_with_ai?: boolean;
22
+ /** Provide 2–25 parts to publish as a thread. Omit for a single tweet. */
23
+ thread_parts?: XThreadPartInput[];
24
+ }
25
+ /**
26
+ * Update-side variant: passing `thread_parts: null` explicitly clears the
27
+ * thread shape on the post (reverting to single-tweet mode). Passing
28
+ * `undefined` (omit it) leaves the existing thread untouched.
29
+ */
30
+ export interface XPostOptionsUpdate extends Omit<XPostOptions, "thread_parts"> {
31
+ thread_parts?: XThreadPartInput[] | null;
32
+ }
12
33
  export declare class OmniSocialsClient {
13
34
  private baseUrl;
14
35
  private apiKey;
@@ -32,7 +53,7 @@ export declare class OmniSocialsClient {
32
53
  youtube?: Record<string, unknown>;
33
54
  instagram?: Record<string, unknown>;
34
55
  tiktok?: Record<string, unknown>;
35
- x?: Record<string, unknown>;
56
+ x?: XPostOptions;
36
57
  }): Promise<ApiResponse<unknown>>;
37
58
  createAndPublishPost(data: {
38
59
  content: string | Record<string, string>;
@@ -45,7 +66,7 @@ export declare class OmniSocialsClient {
45
66
  youtube?: Record<string, unknown>;
46
67
  instagram?: Record<string, unknown>;
47
68
  tiktok?: Record<string, unknown>;
48
- x?: Record<string, unknown>;
69
+ x?: XPostOptions;
49
70
  }): Promise<ApiResponse<unknown>>;
50
71
  updatePost(id: string, data: {
51
72
  content?: string | Record<string, string>;
@@ -58,7 +79,7 @@ export declare class OmniSocialsClient {
58
79
  youtube?: Record<string, unknown>;
59
80
  instagram?: Record<string, unknown>;
60
81
  tiktok?: Record<string, unknown>;
61
- x?: Record<string, unknown>;
82
+ x?: XPostOptionsUpdate;
62
83
  }): Promise<ApiResponse<unknown>>;
63
84
  deletePost(id: string): Promise<ApiResponse<unknown>>;
64
85
  publishPost(id: string): Promise<ApiResponse<unknown>>;
package/build/client.js CHANGED
@@ -51,16 +51,36 @@ export function formatDateTime(iso) {
51
51
  if (!iso)
52
52
  return "—";
53
53
  const d = new Date(iso);
54
+ // Always render in UTC with an explicit "UTC" suffix so the timezone is
55
+ // unambiguous regardless of which Node runtime / ICU build the MCP runs in.
56
+ // Agents can convert to the user's local time when answering.
54
57
  return d.toLocaleDateString("en-US", {
55
58
  month: "short",
56
59
  day: "numeric",
57
60
  year: "numeric",
58
61
  hour: "numeric",
59
62
  minute: "2-digit",
60
- timeZoneName: "short",
61
- });
63
+ timeZone: "UTC",
64
+ }) + " UTC";
62
65
  }
63
- export function truncate(str, len) {
66
+ // Render any caption-like value (string or per-platform object) to a single
67
+ // display string, then truncate. The backend returns `content` as an object
68
+ // with one key per platform plus `default`; older callers may pass a string.
69
+ export function truncate(value, len) {
70
+ let str;
71
+ if (typeof value === "string") {
72
+ str = value;
73
+ }
74
+ else if (value && typeof value === "object") {
75
+ const obj = value;
76
+ const first = (typeof obj.default === "string" && obj.default) ||
77
+ Object.values(obj).find((v) => typeof v === "string" && v.length > 0) ||
78
+ "";
79
+ str = first;
80
+ }
81
+ else {
82
+ return "";
83
+ }
64
84
  if (!str)
65
85
  return "";
66
86
  const clean = str.replace(/\n/g, " ");
@@ -22,13 +22,20 @@ export function registerMediaTools(server, getClient) {
22
22
  md += `|---|------|----------|------|----------|\n`;
23
23
  for (let i = 0; i < items.length; i++) {
24
24
  const m = items[i];
25
- const type = m.type === "video" ? "Video" : "Image";
25
+ // `type` may be "video"/"image" (normalized) or a raw MIME like "video/mp4".
26
+ const rawType = typeof m.type === "string" ? m.type : "";
27
+ const isVideo = rawType === "video" || rawType.startsWith("video/");
28
+ const type = isVideo ? "Video" : "Image";
26
29
  md += `| ${i + 1} | ${type} | ${m.filename || "—"} | ${formatBytes(m.size || 0)} | \`${m.id}\` |\n`;
27
30
  }
28
31
  md += `\nUse the **Media ID** with \`media_ids\` when creating posts.`;
29
32
  // Fetch thumbnails for image files (max 4)
30
33
  const content = [];
31
- const imageItems = items.filter((m) => m.type !== "video" && m.url).slice(0, 4);
34
+ const imageItems = items.filter((m) => {
35
+ const t = typeof m.type === "string" ? m.type : "";
36
+ const isVideo = t === "video" || t.startsWith("video/");
37
+ return !isVideo && m.url;
38
+ }).slice(0, 4);
32
39
  const imagePromises = imageItems.map(async (m) => {
33
40
  const img = await fetchImageAsBase64(m.url);
34
41
  return img ? { ...img, id: m.id } : null;
@@ -1,7 +1,46 @@
1
1
  import { z } from "zod";
2
2
  import { formatDateTime, truncate, capitalize } from "../client.js";
3
+ // Render full content for get_post — preserves per-platform overrides so Claude
4
+ // can see custom captions (e.g. a shorter X version) instead of only `default`.
5
+ function renderContent(content) {
6
+ if (!content)
7
+ return "(empty)";
8
+ if (typeof content === "string")
9
+ return content;
10
+ if (typeof content !== "object")
11
+ return "(empty)";
12
+ const entries = Object.entries(content).filter(([, v]) => typeof v === "string" && v);
13
+ if (!entries.length)
14
+ return "(empty)";
15
+ if (entries.length === 1)
16
+ return entries[0][1];
17
+ return entries
18
+ .map(([key, value]) => `**${key === "default" ? "Default (fallback)" : capitalize(key.replace(/_/g, " "))}**\n\n${value}`)
19
+ .join("\n\n---\n\n");
20
+ }
21
+ // The API returns `accounts` as either an array of platform IDs or an object
22
+ // map `{platform: true|false}`. Older clients may still receive `channels`.
23
+ // Normalize to an array of selected platform names.
24
+ function selectedChannels(p) {
25
+ const src = p?.accounts ?? p?.channels;
26
+ if (!src)
27
+ return [];
28
+ if (Array.isArray(src))
29
+ return src.filter((x) => typeof x === "string" && !!x);
30
+ if (typeof src === "object") {
31
+ return Object.entries(src)
32
+ .filter(([, v]) => !!v)
33
+ .map(([k]) => k);
34
+ }
35
+ return [];
36
+ }
37
+ // Pick the most informative timestamp to display. API returns `schedule_at`;
38
+ // `scheduled_at` is kept as a defensive fallback for older response shapes.
39
+ function displayDate(p) {
40
+ return p?.schedule_at ?? p?.scheduled_at ?? p?.published_at ?? p?.created_at ?? null;
41
+ }
3
42
  export function registerPostTools(server, getClient) {
4
- server.tool("list_posts", "List all posts in the workspace. Optionally filter by status.", {
43
+ server.tool("list_posts", "List all posts in the workspace. Optionally filter by status. The content column shows a preview of the default caption; if a post has per-platform overrides (e.g. a custom X version), the preview is suffixed with *(per-platform)* — call `get_post` to see every variant.", {
5
44
  status: z.string().optional().describe("Filter by status: draft, scheduled, published, failed"),
6
45
  limit: z.string().optional().describe("Max results to return (default: 20)"),
7
46
  offset: z.string().optional().describe("Offset for pagination"),
@@ -24,24 +63,31 @@ export function registerPostTools(server, getClient) {
24
63
  md += `|---|---------|----------|--------|------|----|\n`;
25
64
  for (let i = 0; i < posts.length; i++) {
26
65
  const p = posts[i];
27
- const rawContent = typeof p.content === "object" && p.content !== null
66
+ const isObj = typeof p.content === "object" && p.content !== null;
67
+ const rawContent = isObj
28
68
  ? (p.content.default || Object.values(p.content).find((v) => typeof v === "string" && v) || "")
29
69
  : (p.content || "");
30
- const content = truncate(rawContent, 50);
31
- const channels = (p.channels || []).join(", ");
70
+ const hasOverrides = isObj && Object.keys(p.content).filter((k) => k !== "default" && typeof p.content[k] === "string" && p.content[k]).length > 0;
71
+ const threadParts = Array.isArray(p.x?.thread_parts) ? p.x.thread_parts : [];
72
+ const isThread = threadParts.length >= 2;
73
+ let content;
74
+ if (isThread && !rawContent) {
75
+ const first = typeof threadParts[0]?.text === "string" ? threadParts[0].text : "";
76
+ content = truncate(first, 32) + ` *(X thread, ${threadParts.length} parts)*`;
77
+ }
78
+ else {
79
+ content = truncate(rawContent, hasOverrides ? 35 : 50) + (hasOverrides ? " *(per-platform)*" : "");
80
+ }
81
+ const channels = selectedChannels(p).join(", ") || "—";
32
82
  const status = capitalize(p.status || "—");
33
- const date = p.scheduled_at
34
- ? formatDateTime(p.scheduled_at)
35
- : p.published_at
36
- ? formatDateTime(p.published_at)
37
- : formatDateTime(p.created_at);
83
+ const date = formatDateTime(displayDate(p));
38
84
  md += `| ${i + 1} | ${content} | ${channels} | ${status} | ${date} | \`${p.id}\` |\n`;
39
85
  }
40
86
  return {
41
87
  content: [{ type: "text", text: md }],
42
88
  };
43
89
  });
44
- server.tool("get_post", "Get details of a specific post by ID.", {
90
+ server.tool("get_post", "Get details of a specific post by ID. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
45
91
  id: z.string().describe("The post ID"),
46
92
  }, async ({ id }) => {
47
93
  const result = await getClient().getPost(id);
@@ -59,19 +105,47 @@ export function registerPostTools(server, getClient) {
59
105
  let md = `## Post Details\n\n`;
60
106
  md += `| Field | Value |\n`;
61
107
  md += `|-------|-------|\n`;
108
+ const schedAt = p.schedule_at ?? p.scheduled_at;
62
109
  md += `| **ID** | \`${p.id}\` |\n`;
63
110
  md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
64
111
  md += `| **Type** | ${p.type || "post"} |\n`;
65
- md += `| **Channels** | ${(p.channels || []).join(", ") || "—"} |\n`;
66
- if (p.scheduled_at)
67
- md += `| **Scheduled** | ${formatDateTime(p.scheduled_at)} |\n`;
112
+ md += `| **Channels** | ${selectedChannels(p).join(", ") || "—"} |\n`;
113
+ if (schedAt)
114
+ md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
68
115
  if (p.published_at)
69
116
  md += `| **Published** | ${formatDateTime(p.published_at)} |\n`;
70
117
  md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
71
118
  if (p.media?.length) {
72
119
  md += `| **Media** | ${p.media.length} file(s) |\n`;
73
120
  }
74
- md += `\n### Content\n\n${p.content || "(empty)"}`;
121
+ md += `\n### Content\n\n${renderContent(p.content)}`;
122
+ // X thread: when present, the canonical tweet text lives here, not in `content`.
123
+ const threadParts = Array.isArray(p.x?.thread_parts)
124
+ ? p.x.thread_parts
125
+ : [];
126
+ if (threadParts.length >= 2) {
127
+ md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
128
+ for (let i = 0; i < threadParts.length; i++) {
129
+ const part = threadParts[i] || {};
130
+ const text = typeof part.text === "string" ? part.text : "";
131
+ md += `**${i + 1}/${threadParts.length}.** ${text}\n`;
132
+ if (Array.isArray(part.media_urls) && part.media_urls.length) {
133
+ for (const url of part.media_urls)
134
+ md += ` - ${url}\n`;
135
+ }
136
+ md += `\n`;
137
+ }
138
+ }
139
+ const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
140
+ ? p.published_urls
141
+ : {};
142
+ const urlEntries = Object.entries(publishedUrls);
143
+ if (urlEntries.length) {
144
+ md += `\n\n### Published URLs\n\n`;
145
+ for (const [platform, url] of urlEntries) {
146
+ md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
147
+ }
148
+ }
75
149
  return {
76
150
  content: [{ type: "text", text: md }],
77
151
  };
@@ -80,6 +154,7 @@ export function registerPostTools(server, getClient) {
80
154
 
81
155
  IMPORTANT — Before calling this tool, make sure you have all required information from the user. If anything is missing, ASK the user before calling:
82
156
 
157
+ 0. **Workspace**: If the user has only one workspace, just use it (no need to ask). If the user has multiple workspaces and has NOT named one in their request, call list_workspaces and ask which workspace to post to before calling this tool. If the user has named a workspace, switch to it once and remember the choice for the rest of the conversation. Never silently pick a workspace when more than one exists. After a successful create/schedule, mention the workspace name in your reply to the user for clarity (e.g. "Scheduled to the Acme workspace for Tuesday 3pm").
83
158
  1. **Content/caption**: What text should the post have? If captions differ per platform, use one call with an object: { "default": "fallback text", "linkedin": "long version", "threads": "short version" }. Always prefer one call per topic.
84
159
  2. **Channels**: Which platforms to post to? Use list_accounts to show available options if needed.
85
160
  3. **Schedule**: When should it be published? (Or save as draft?)
@@ -91,9 +166,14 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
91
166
  - Pinterest posts: ALWAYS require an image AND a board_id.
92
167
  - Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
93
168
  5. **Platform-specific options** (ask only when relevant):
94
- - Pinterest: Which board? Any link to attach?
169
+ - **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking the user — and do NOT skip Pinterest. Instead:
170
+ 1. Call \`get_account\` on the Pinterest account ID — the response includes a \`Pinterest Boards\` table with each board's name and ID.
171
+ 2. Use the FIRST board in that list as \`pinterest.board_id\` automatically.
172
+ 3. In your reply to the user after the post is created/scheduled, explicitly mention which board you used (e.g. "Posted to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one and I'll move it.") so they can correct course.
173
+ 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.
95
174
  - YouTube: Title, privacy status, tags?
96
175
  - TikTok: Privacy level?
176
+ - **X threads**: When the user asks for an X/Twitter "thread" (or anything that exceeds 280 chars on X), DO NOT cram it into \`content\` with "1/", "2/" prefixes. Instead pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars). The \`content\` field is then ignored for X. For a single tweet, omit \`thread_parts\` and use \`content\` normally.
97
177
 
98
178
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
99
179
 
@@ -140,6 +220,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
140
220
  }).optional().describe("TikTok options"),
141
221
  x: z.object({
142
222
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
223
+ paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
224
+ made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
225
+ thread_parts: z.array(z.object({
226
+ text: z.string().describe("Tweet text (≤ 280 chars)"),
227
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
228
+ })).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Per-part media_urls override top-level media for that specific tweet. For a single tweet, omit thread_parts and use content."),
143
229
  }).optional().describe("X (Twitter) options"),
144
230
  }, async (params) => {
145
231
  const result = await getClient().createPost({ ...params, source: "mcp" });
@@ -149,24 +235,36 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
149
235
  };
150
236
  }
151
237
  const p = result.data;
152
- let md = `## Post Created\n\n`;
153
- md += `| Field | Value |\n`;
154
- md += `|-------|-------|\n`;
155
- md += `| **ID** | \`${p.id}\` |\n`;
156
- md += `| **Status** | ${capitalize(p.status || "draft")} |\n`;
157
- if (p.scheduled_at)
158
- md += `| **Scheduled** | ${formatDateTime(p.scheduled_at)} |\n`;
159
- if (p.channels?.length)
160
- md += `| **Channels** | ${p.channels.join(", ")} |\n`;
161
- md += `\n**Content:** ${truncate(p.content || "", 100)}`;
162
- return {
163
- content: [{ type: "text", text: md }],
164
- };
238
+ // The post is already created by the time this runs. If response
239
+ // formatting throws, we must NOT surface it as a tool error — agents
240
+ // retry on errors and that produces orphan duplicate posts.
241
+ try {
242
+ const schedAt = p.schedule_at ?? p.scheduled_at;
243
+ const channels = selectedChannels(p);
244
+ let md = `## Post Created\n\n`;
245
+ md += `| Field | Value |\n`;
246
+ md += `|-------|-------|\n`;
247
+ md += `| **ID** | \`${p.id}\` |\n`;
248
+ md += `| **Status** | ${capitalize(p.status || "draft")} |\n`;
249
+ if (schedAt)
250
+ md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
251
+ if (channels.length)
252
+ md += `| **Channels** | ${channels.join(", ")} |\n`;
253
+ md += `\n**Content:** ${truncate(p.content, 100)}`;
254
+ return { content: [{ type: "text", text: md }] };
255
+ }
256
+ catch {
257
+ // Fall back to the minimum the agent needs to confirm success — never error.
258
+ return {
259
+ content: [{ type: "text", text: `## Post Created\n\n- **ID:** \`${p.id}\`\n- **Status:** ${p.status || "draft"}\n\n_Post was created successfully. Use \`get_post\` with the ID above to see full details._` }],
260
+ };
261
+ }
165
262
  });
166
263
  server.tool("create_and_publish_post", `Create a new post and publish it immediately (no scheduling).
167
264
 
168
265
  IMPORTANT — Before calling this tool, make sure you have all required information. If anything is missing, ASK the user first:
169
266
 
267
+ 0. **Workspace**: If the user has only one workspace, just use it (no need to ask). If the user has multiple workspaces and has NOT named one, call list_workspaces and ask before publishing. If the user has named a workspace, switch to it once and remember it. Never silently pick a workspace when more than one exists — published posts are public. After publishing, mention the workspace name in your reply (e.g. "Published to the Acme workspace LinkedIn Page").
170
268
  1. **Content/caption**: What text? If captions differ per platform, use one call with an object: { "default": "fallback", "linkedin": "long", "threads": "short" }.
171
269
  2. **Channels**: Which platforms? Use list_accounts if needed.
172
270
  3. **Media** (REQUIRED for some types — same rules as create_post):
@@ -175,6 +273,8 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
175
273
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
176
274
  - Pinterest: ALWAYS need an image + board_id.
177
275
  - Ask the user which media to use. Use list_media to show options.
276
+ 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.
277
+ 5. **X threads**: When the user asks for an X/Twitter "thread" (or anything > 280 chars on X), pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
178
278
 
179
279
  Do NOT call without required media — it will fail.`, {
180
280
  content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
@@ -201,6 +301,15 @@ Do NOT call without required media — it will fail.`, {
201
301
  tiktok: z.object({
202
302
  privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
203
303
  }).optional().describe("TikTok options"),
304
+ x: z.object({
305
+ reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
306
+ paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
307
+ made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
308
+ thread_parts: z.array(z.object({
309
+ text: z.string().describe("Tweet text (≤ 280 chars)"),
310
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
311
+ })).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Per-part media_urls override top-level media."),
312
+ }).optional().describe("X (Twitter) options"),
204
313
  }, async (params) => {
205
314
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
206
315
  if (result.error) {
@@ -209,19 +318,28 @@ Do NOT call without required media — it will fail.`, {
209
318
  };
210
319
  }
211
320
  const p = result.data;
212
- let md = `## Post Created & Publishing\n\n`;
213
- md += `| Field | Value |\n`;
214
- md += `|-------|-------|\n`;
215
- md += `| **ID** | \`${p.id}\` |\n`;
216
- md += `| **Status** | ${capitalize(p.status || "publishing")} |\n`;
217
- if (p.channels?.length)
218
- md += `| **Channels** | ${p.channels.join(", ")} |\n`;
219
- md += `\n**Content:** ${truncate(p.content || "", 100)}`;
220
- return {
221
- content: [{ type: "text", text: md }],
222
- };
321
+ try {
322
+ const channels = selectedChannels(p);
323
+ let md = `## Post Created & Publishing\n\n`;
324
+ md += `| Field | Value |\n`;
325
+ md += `|-------|-------|\n`;
326
+ md += `| **ID** | \`${p.id}\` |\n`;
327
+ md += `| **Status** | ${capitalize(p.status || "publishing")} |\n`;
328
+ if (channels.length)
329
+ md += `| **Channels** | ${channels.join(", ")} |\n`;
330
+ md += `\n**Content:** ${truncate(p.content, 100)}`;
331
+ return { content: [{ type: "text", text: md }] };
332
+ }
333
+ catch {
334
+ // The post is already queued; never let a formatting throw look like an API error.
335
+ return {
336
+ content: [{ type: "text", text: `## Post Created & Publishing\n\n- **ID:** \`${p.id}\`\n- **Status:** ${p.status || "publishing"}\n\n_Post was queued successfully. Use \`get_post\` with the ID above for full details._` }],
337
+ };
338
+ }
223
339
  });
224
- server.tool("update_post", "Update an existing post. Only draft and scheduled posts can be updated.", {
340
+ server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
341
+
342
+ **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.`, {
225
343
  id: z.string().describe("The post ID to update"),
226
344
  content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post content. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
227
345
  scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
@@ -234,6 +352,15 @@ Do NOT call without required media — it will fail.`, {
234
352
  z.array(z.string()),
235
353
  z.record(z.string(), z.array(z.string())),
236
354
  ]).optional().describe("External URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
355
+ x: z.object({
356
+ reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
357
+ paid_partnership: z.boolean().optional(),
358
+ made_with_ai: z.boolean().optional(),
359
+ thread_parts: z.array(z.object({
360
+ text: z.string().describe("Tweet text (≤ 280 chars)"),
361
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
362
+ })).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread, or `null` to revert to single-tweet mode."),
363
+ }).optional().describe("X (Twitter) options"),
237
364
  }, async ({ id, ...data }) => {
238
365
  const result = await getClient().updatePost(id, data);
239
366
  if (result.error) {
@@ -242,13 +369,14 @@ Do NOT call without required media — it will fail.`, {
242
369
  };
243
370
  }
244
371
  const p = result.data;
372
+ const schedAt = p.schedule_at ?? p.scheduled_at;
245
373
  let md = `## Post Updated\n\n`;
246
374
  md += `| Field | Value |\n`;
247
375
  md += `|-------|-------|\n`;
248
376
  md += `| **ID** | \`${p.id}\` |\n`;
249
377
  md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
250
- if (p.scheduled_at)
251
- md += `| **Scheduled** | ${formatDateTime(p.scheduled_at)} |\n`;
378
+ if (schedAt)
379
+ md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
252
380
  return {
253
381
  content: [{ type: "text", text: md }],
254
382
  };
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  export function registerWorkspaceTools(server, workspaceClients, sessionState) {
3
- server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active.", {}, async () => {
3
+ server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, async () => {
4
4
  const workspaces = await Promise.all(workspaceClients.map(async (wc, index) => {
5
5
  try {
6
6
  const result = await wc.client.listAccounts();
@@ -39,7 +39,7 @@ export function registerWorkspaceTools(server, workspaceClients, sessionState) {
39
39
  md += `\nUse **switch_workspace** with the workspace number to change the active workspace.`;
40
40
  return { content: [{ type: "text", text: md }] };
41
41
  });
42
- server.tool("switch_workspace", "Switch the active workspace. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace. Use list_workspaces first to see available options.", {
42
+ server.tool("switch_workspace", "Switch the active workspace. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace. Only call this when the user has explicitly named the target workspace, or after the user has picked one from list_workspaces. Never call switch_workspace silently when the user hasn't specified a workspace — ask first.", {
43
43
  workspace_number: z.string().describe("The workspace number from list_workspaces (1, 2, 3, etc.)"),
44
44
  }, async ({ workspace_number }) => {
45
45
  const index = parseInt(workspace_number, 10) - 1;
package/build/types.d.ts CHANGED
@@ -14,6 +14,17 @@ export interface Post {
14
14
  channels: string[];
15
15
  media: string[];
16
16
  created_at: string;
17
+ x?: {
18
+ reply_settings?: string;
19
+ paid_partnership?: boolean;
20
+ made_with_ai?: boolean;
21
+ thread_parts?: Array<{
22
+ id?: string;
23
+ text: string;
24
+ media_urls?: string[];
25
+ }>;
26
+ [key: string]: unknown;
27
+ };
17
28
  }
18
29
  export interface MediaItem {
19
30
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
5
5
  "type": "module",
6
6
  "main": "build/index.js",