@omnisocials/mcp-server 1.3.0 → 1.3.1

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
@@ -9,6 +9,27 @@ export declare function formatDate(iso: string | null): string;
9
9
  export declare function formatDateTime(iso: string | null): string;
10
10
  export declare function truncate(str: string, 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>>;
@@ -1,7 +1,25 @@
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
+ }
3
21
  export function registerPostTools(server, getClient) {
4
- server.tool("list_posts", "List all posts in the workspace. Optionally filter by status.", {
22
+ 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
23
  status: z.string().optional().describe("Filter by status: draft, scheduled, published, failed"),
6
24
  limit: z.string().optional().describe("Max results to return (default: 20)"),
7
25
  offset: z.string().optional().describe("Offset for pagination"),
@@ -24,10 +42,21 @@ export function registerPostTools(server, getClient) {
24
42
  md += `|---|---------|----------|--------|------|----|\n`;
25
43
  for (let i = 0; i < posts.length; i++) {
26
44
  const p = posts[i];
27
- const rawContent = typeof p.content === "object" && p.content !== null
45
+ const isObj = typeof p.content === "object" && p.content !== null;
46
+ const rawContent = isObj
28
47
  ? (p.content.default || Object.values(p.content).find((v) => typeof v === "string" && v) || "")
29
48
  : (p.content || "");
30
- const content = truncate(rawContent, 50);
49
+ const hasOverrides = isObj && Object.keys(p.content).filter((k) => k !== "default" && typeof p.content[k] === "string" && p.content[k]).length > 0;
50
+ const threadParts = Array.isArray(p.x?.thread_parts) ? p.x.thread_parts : [];
51
+ const isThread = threadParts.length >= 2;
52
+ let content;
53
+ if (isThread && !rawContent) {
54
+ const first = typeof threadParts[0]?.text === "string" ? threadParts[0].text : "";
55
+ content = truncate(first, 32) + ` *(X thread, ${threadParts.length} parts)*`;
56
+ }
57
+ else {
58
+ content = truncate(rawContent, hasOverrides ? 35 : 50) + (hasOverrides ? " *(per-platform)*" : "");
59
+ }
31
60
  const channels = (p.channels || []).join(", ");
32
61
  const status = capitalize(p.status || "—");
33
62
  const date = p.scheduled_at
@@ -41,7 +70,7 @@ export function registerPostTools(server, getClient) {
41
70
  content: [{ type: "text", text: md }],
42
71
  };
43
72
  });
44
- server.tool("get_post", "Get details of a specific post by ID.", {
73
+ 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
74
  id: z.string().describe("The post ID"),
46
75
  }, async ({ id }) => {
47
76
  const result = await getClient().getPost(id);
@@ -71,7 +100,34 @@ export function registerPostTools(server, getClient) {
71
100
  if (p.media?.length) {
72
101
  md += `| **Media** | ${p.media.length} file(s) |\n`;
73
102
  }
74
- md += `\n### Content\n\n${p.content || "(empty)"}`;
103
+ md += `\n### Content\n\n${renderContent(p.content)}`;
104
+ // X thread: when present, the canonical tweet text lives here, not in `content`.
105
+ const threadParts = Array.isArray(p.x?.thread_parts)
106
+ ? p.x.thread_parts
107
+ : [];
108
+ if (threadParts.length >= 2) {
109
+ md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
110
+ for (let i = 0; i < threadParts.length; i++) {
111
+ const part = threadParts[i] || {};
112
+ const text = typeof part.text === "string" ? part.text : "";
113
+ md += `**${i + 1}/${threadParts.length}.** ${text}\n`;
114
+ if (Array.isArray(part.media_urls) && part.media_urls.length) {
115
+ for (const url of part.media_urls)
116
+ md += ` - ${url}\n`;
117
+ }
118
+ md += `\n`;
119
+ }
120
+ }
121
+ const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
122
+ ? p.published_urls
123
+ : {};
124
+ const urlEntries = Object.entries(publishedUrls);
125
+ if (urlEntries.length) {
126
+ md += `\n\n### Published URLs\n\n`;
127
+ for (const [platform, url] of urlEntries) {
128
+ md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
129
+ }
130
+ }
75
131
  return {
76
132
  content: [{ type: "text", text: md }],
77
133
  };
@@ -80,6 +136,7 @@ export function registerPostTools(server, getClient) {
80
136
 
81
137
  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
138
 
139
+ 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
140
  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
141
  2. **Channels**: Which platforms to post to? Use list_accounts to show available options if needed.
85
142
  3. **Schedule**: When should it be published? (Or save as draft?)
@@ -94,6 +151,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
94
151
  - Pinterest: Which board? Any link to attach?
95
152
  - YouTube: Title, privacy status, tags?
96
153
  - TikTok: Privacy level?
154
+ - **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
155
 
98
156
  Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
99
157
 
@@ -140,6 +198,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
140
198
  }).optional().describe("TikTok options"),
141
199
  x: z.object({
142
200
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
201
+ paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
202
+ made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
203
+ thread_parts: z.array(z.object({
204
+ text: z.string().describe("Tweet text (≤ 280 chars)"),
205
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
206
+ })).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
207
  }).optional().describe("X (Twitter) options"),
144
208
  }, async (params) => {
145
209
  const result = await getClient().createPost({ ...params, source: "mcp" });
@@ -167,6 +231,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
167
231
 
168
232
  IMPORTANT — Before calling this tool, make sure you have all required information. If anything is missing, ASK the user first:
169
233
 
234
+ 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
235
  1. **Content/caption**: What text? If captions differ per platform, use one call with an object: { "default": "fallback", "linkedin": "long", "threads": "short" }.
171
236
  2. **Channels**: Which platforms? Use list_accounts if needed.
172
237
  3. **Media** (REQUIRED for some types — same rules as create_post):
@@ -175,6 +240,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
175
240
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
176
241
  - Pinterest: ALWAYS need an image + board_id.
177
242
  - Ask the user which media to use. Use list_media to show options.
243
+ 4. **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
244
 
179
245
  Do NOT call without required media — it will fail.`, {
180
246
  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 +267,15 @@ Do NOT call without required media — it will fail.`, {
201
267
  tiktok: z.object({
202
268
  privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
203
269
  }).optional().describe("TikTok options"),
270
+ x: z.object({
271
+ reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
272
+ paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
273
+ made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
274
+ thread_parts: z.array(z.object({
275
+ text: z.string().describe("Tweet text (≤ 280 chars)"),
276
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
277
+ })).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."),
278
+ }).optional().describe("X (Twitter) options"),
204
279
  }, async (params) => {
205
280
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
206
281
  if (result.error) {
@@ -221,7 +296,9 @@ Do NOT call without required media — it will fail.`, {
221
296
  content: [{ type: "text", text: md }],
222
297
  };
223
298
  });
224
- server.tool("update_post", "Update an existing post. Only draft and scheduled posts can be updated.", {
299
+ server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
300
+
301
+ **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
302
  id: z.string().describe("The post ID to update"),
226
303
  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
304
  scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
@@ -234,6 +311,15 @@ Do NOT call without required media — it will fail.`, {
234
311
  z.array(z.string()),
235
312
  z.record(z.string(), z.array(z.string())),
236
313
  ]).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."),
314
+ x: z.object({
315
+ reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
316
+ paid_partnership: z.boolean().optional(),
317
+ made_with_ai: z.boolean().optional(),
318
+ thread_parts: z.array(z.object({
319
+ text: z.string().describe("Tweet text (≤ 280 chars)"),
320
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
321
+ })).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."),
322
+ }).optional().describe("X (Twitter) options"),
237
323
  }, async ({ id, ...data }) => {
238
324
  const result = await getClient().updatePost(id, data);
239
325
  if (result.error) {
@@ -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.1",
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",