@omnisocials/mcp-server 1.4.1 → 1.7.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
@@ -128,13 +128,16 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
128
128
  | `publish_post` | Publish a draft or scheduled post now |
129
129
  | `search_locations` | Find an Instagram location to tag (returns place IDs for `location_id`) |
130
130
 
131
- ### Media (3 tools)
131
+ ### Media & folders (6 tools)
132
132
 
133
133
  | Tool | Description |
134
134
  |------|-------------|
135
- | `list_media` | List all media files |
136
- | `upload_media` | Upload media from a URL (max 50MB) |
135
+ | `list_media` | List media files; filter by `search` (name) or `folder_id` |
136
+ | `upload_media` | Upload media from a URL (max 50MB); set a `name` and a `folder` |
137
+ | `update_media` | Rename a media file or move it to a folder |
137
138
  | `delete_media` | Delete a media file |
139
+ | `list_folders` | List the workspace's media folders |
140
+ | `create_folder` | Create a media folder (optionally nested) |
138
141
 
139
142
  ### Accounts (2 tools)
140
143
 
@@ -156,7 +159,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
156
159
  | Tool | Description |
157
160
  |------|-------------|
158
161
  | `list_workspaces` | List all workspaces available in this session |
159
- | `switch_workspace` | Switch the active workspace |
162
+ | `switch_workspace` | Switch the active workspace **by name** (id or list number also accepted) |
160
163
 
161
164
  ### Webhooks (5 tools)
162
165
 
@@ -192,6 +195,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
192
195
 
193
196
  ## Changelog
194
197
 
198
+ ### 1.5.0
199
+
200
+ - **Added:** Attach uploaded media to any tweet in an X thread. `x.thread_parts[]` now accepts `media_ids` (the same numeric Library IDs returned by `upload_media`) on any part, first tweet or reply, alongside the existing `media_urls`. Combined cap is 4 media per part. This makes graphic-led threads possible without self-hosting image URLs: for example a graphic on the first tweet and the signup link alone in a reply (which keeps the first tweet's reach). Companion server change: media attached to the parent post was previously dropped in thread mode and is now folded into the first tweet.
201
+ - **Changed:** `upload_media` now shows the uploaded file's public URL in its output, so it can be reused anywhere `media_urls` is accepted.
202
+
195
203
  ### 1.4.1
196
204
 
197
205
  - **Fixed:** Long-form X posts from Premium / Premium+ accounts are no longer wrongly capped at 280 characters. Companion server fix — the API validated X text without checking the account's subscription tier, so single posts over 280 chars were rejected even on Premium. Existing clients benefit automatically once the backend deploys; 1.4.1 only refreshes the tool guidance so agents put Premium long-form (up to 25,000 chars) in `content` instead of force-splitting it into a thread. Note: X *threads* still cap each part at 280 chars regardless of tier.
package/build/client.d.ts CHANGED
@@ -12,7 +12,9 @@ export declare function capitalize(str: string): string;
12
12
  export interface XThreadPartInput {
13
13
  /** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
14
14
  text: string;
15
- /** Optional per-part media URLs (max 4). Overrides top-level media_urls.x. */
15
+ /** Optional per-part media as Library IDs from upload_media (max 4 combined with media_urls). */
16
+ media_ids?: string[];
17
+ /** Optional per-part media as external URLs (max 4 combined with media_ids). */
16
18
  media_urls?: string[];
17
19
  }
18
20
  export interface XPostOptions {
@@ -54,6 +56,13 @@ export declare class OmniSocialsClient {
54
56
  link_description?: string;
55
57
  link_thumbnail_url?: string;
56
58
  location_id?: string;
59
+ collaborators?: string[];
60
+ user_tags?: Array<{
61
+ username: string;
62
+ x: number;
63
+ y: number;
64
+ image_index?: number;
65
+ }>;
57
66
  pinterest?: Record<string, unknown>;
58
67
  youtube?: Record<string, unknown>;
59
68
  instagram?: Record<string, unknown>;
@@ -73,6 +82,13 @@ export declare class OmniSocialsClient {
73
82
  link_description?: string;
74
83
  link_thumbnail_url?: string;
75
84
  location_id?: string;
85
+ collaborators?: string[];
86
+ user_tags?: Array<{
87
+ username: string;
88
+ x: number;
89
+ y: number;
90
+ image_index?: number;
91
+ }>;
76
92
  pinterest?: Record<string, unknown>;
77
93
  youtube?: Record<string, unknown>;
78
94
  instagram?: Record<string, unknown>;
@@ -88,6 +104,13 @@ export declare class OmniSocialsClient {
88
104
  media_urls?: string[] | Record<string, string[]>;
89
105
  type?: string;
90
106
  location_id?: string;
107
+ collaborators?: string[];
108
+ user_tags?: Array<{
109
+ username: string;
110
+ x: number;
111
+ y: number;
112
+ image_index?: number;
113
+ }>;
91
114
  pinterest?: Record<string, unknown>;
92
115
  youtube?: Record<string, unknown>;
93
116
  instagram?: Record<string, unknown>;
@@ -102,17 +125,32 @@ export declare class OmniSocialsClient {
102
125
  listMedia(params?: {
103
126
  limit?: string;
104
127
  offset?: string;
128
+ search?: string;
129
+ folder_id?: string;
105
130
  }): Promise<ApiResponse<unknown>>;
106
131
  uploadMedia(data: {
107
132
  url: string;
108
133
  filename?: string;
134
+ name?: string;
135
+ folder?: string;
109
136
  }): Promise<ApiResponse<unknown>>;
110
137
  uploadMediaFromBase64(data: {
111
138
  data: string;
112
139
  mime_type: string;
113
140
  filename?: string;
141
+ name?: string;
142
+ folder?: string;
114
143
  }): Promise<ApiResponse<unknown>>;
115
144
  deleteMedia(id: string): Promise<ApiResponse<unknown>>;
145
+ updateMedia(id: string, data: {
146
+ name?: string;
147
+ folder_id?: string | null;
148
+ }): Promise<ApiResponse<unknown>>;
149
+ listFolders(): Promise<ApiResponse<unknown>>;
150
+ createFolder(data: {
151
+ name: string;
152
+ parent_id?: string;
153
+ }): Promise<ApiResponse<unknown>>;
116
154
  listAccounts(): Promise<ApiResponse<unknown>>;
117
155
  getAccount(id: string): Promise<ApiResponse<unknown>>;
118
156
  getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
package/build/client.js CHANGED
@@ -170,6 +170,16 @@ export class OmniSocialsClient {
170
170
  async deleteMedia(id) {
171
171
  return this.request("DELETE", `/media/${id}`);
172
172
  }
173
+ async updateMedia(id, data) {
174
+ return this.request("PATCH", `/media/${id}`, data);
175
+ }
176
+ // Folders
177
+ async listFolders() {
178
+ return this.request("GET", "/folders");
179
+ }
180
+ async createFolder(data) {
181
+ return this.request("POST", "/folders", data);
182
+ }
173
183
  // Accounts
174
184
  async listAccounts() {
175
185
  return this.request("GET", "/accounts");
package/build/index.js CHANGED
@@ -27,7 +27,7 @@ const sessionState = { activeIndex: 0 };
27
27
  const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
28
28
  const server = new McpServer({
29
29
  name: "OmniSocials",
30
- version: "1.2.0",
30
+ version: "1.7.0",
31
31
  });
32
32
  // Register all tools - pass getter function so tools always use the active workspace's client
33
33
  registerPostTools(server, getActiveClient);
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { formatNumber, capitalize } from "../client.js";
3
3
  export function registerAnalyticsTools(server, getClient) {
4
4
  server.tool("get_post_analytics", "Get analytics/statistics for a specific published post (impressions, engagements, likes, etc.).", {
5
- post_id: z.string().describe("The post ID to get analytics for"),
5
+ post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
6
6
  }, async ({ post_id }) => {
7
7
  const result = await getClient().getPostAnalytics(post_id);
8
8
  if (result.error) {
@@ -1,9 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { fetchImageAsBase64, formatBytes } from "../client.js";
3
3
  export function registerMediaTools(server, getClient) {
4
- server.tool("list_media", "List all media files in the workspace. Returns each file's ID, URL, type (image/video), and size. Use this to help users pick media for posts, stories, or reels. Media IDs or URLs from this list can be passed to create_post.", {
4
+ server.tool("list_media", "List media files in the workspace. Returns each file's ID, name, folder, type (image/video), and size. To reuse an existing graphic, SEARCH for it by name here FIRST instead of re-uploading — re-uploading the same image creates duplicates. Media IDs from this list can be passed to create_post. Organize assets into folders with create_folder / list_folders.", {
5
5
  limit: z.string().optional().describe("Max results to return"),
6
6
  offset: z.string().optional().describe("Offset for pagination"),
7
+ search: z.string().optional().describe('Find a file by name or filename, e.g. "play5get50". Use this before uploading to check if the graphic already exists.'),
8
+ folder_id: z.string().optional().describe('Only return media in this folder id (from list_folders). Use "root" for unfiled items.'),
7
9
  }, async (params) => {
8
10
  const result = await getClient().listMedia(params);
9
11
  if (result.error) {
@@ -13,22 +15,26 @@ export function registerMediaTools(server, getClient) {
13
15
  }
14
16
  const items = Array.isArray(result.data) ? result.data : result.data?.media || [];
15
17
  if (!items.length) {
18
+ const hint = params?.search
19
+ ? `No media found matching "${params.search}". It hasn't been uploaded yet — upload it (and pass a \`name\`) so it's findable next time.`
20
+ : "No media files found.";
16
21
  return {
17
- content: [{ type: "text", text: "No media files found." }],
22
+ content: [{ type: "text", text: hint }],
18
23
  };
19
24
  }
20
25
  let md = `## Media Library (${items.length} files)\n\n`;
21
- md += `| # | Type | Filename | Size | Media ID |\n`;
22
- md += `|---|------|----------|------|----------|\n`;
26
+ md += `| # | Type | Name | Folder | Size | Media ID |\n`;
27
+ md += `|---|------|------|--------|------|----------|\n`;
23
28
  for (let i = 0; i < items.length; i++) {
24
29
  const m = items[i];
25
30
  // `type` may be "video"/"image" (normalized) or a raw MIME like "video/mp4".
26
31
  const rawType = typeof m.type === "string" ? m.type : "";
27
32
  const isVideo = rawType === "video" || rawType.startsWith("video/");
28
33
  const type = isVideo ? "Video" : "Image";
29
- md += `| ${i + 1} | ${type} | ${m.filename || "—"} | ${formatBytes(m.size || 0)} | \`${m.id}\` |\n`;
34
+ const folder = m.folder_id ? `\`${m.folder_id}\`` : "—";
35
+ md += `| ${i + 1} | ${type} | ${m.name || m.filename || "—"} | ${folder} | ${formatBytes(m.size || 0)} | \`${m.id}\` |\n`;
30
36
  }
31
- md += `\nUse the **Media ID** with \`media_ids\` when creating posts.`;
37
+ md += `\nUse the **Media ID** with \`media_ids\` when creating posts. Rename or move a file with \`update_media\`. See folders with \`list_folders\`.`;
32
38
  // Fetch thumbnails for image files (max 4)
33
39
  const content = [];
34
40
  const imageItems = items.filter((m) => {
@@ -56,6 +62,8 @@ When the user provides an image in the conversation (not a URL), use base64_data
56
62
  base64_data: z.string().optional().describe("Base64-encoded file data. Use when the user provides an image directly in chat rather than a URL."),
57
63
  mime_type: z.string().optional().describe("MIME type of the file (e.g. 'image/jpeg', 'image/png'). Required when using base64_data."),
58
64
  filename: z.string().optional().describe("Optional filename for the uploaded media"),
65
+ name: z.string().optional().describe('Human-readable label so you can find this asset by name later instead of re-uploading it, e.g. "pp-play5get50". Strongly recommended on every upload.'),
66
+ folder: z.string().optional().describe('Optional folder name to file this asset under (created at the top level if it does not exist), e.g. "win-graphics". See list_folders.'),
59
67
  }, async (params) => {
60
68
  let result;
61
69
  if (params.base64_data && params.mime_type) {
@@ -63,10 +71,12 @@ When the user provides an image in the conversation (not a URL), use base64_data
63
71
  data: params.base64_data,
64
72
  mime_type: params.mime_type,
65
73
  filename: params.filename,
74
+ name: params.name || params.filename,
75
+ folder: params.folder,
66
76
  });
67
77
  }
68
78
  else if (params.url) {
69
- result = await getClient().uploadMedia({ url: params.url, filename: params.filename });
79
+ result = await getClient().uploadMedia({ url: params.url, filename: params.filename, name: params.name || params.filename, folder: params.folder });
70
80
  }
71
81
  else {
72
82
  return {
@@ -83,20 +93,76 @@ When the user provides an image in the conversation (not a URL), use base64_data
83
93
  md += `| Field | Value |\n`;
84
94
  md += `|-------|-------|\n`;
85
95
  md += `| **Media ID** | \`${m.id}\` |\n`;
96
+ md += `| **Name** | ${m.name || m.filename || "—"} |\n`;
86
97
  md += `| **Type** | ${m.type || "—"} |\n`;
87
- md += `| **Filename** | ${m.filename || "—"} |\n`;
98
+ if (m.folder_id)
99
+ md += `| **Folder** | \`${m.folder_id}\` |\n`;
88
100
  md += `| **Size** | ${formatBytes(m.size || 0)} |\n`;
89
- md += `\nUse this Media ID with \`media_ids\` when creating posts.`;
101
+ if (m.url)
102
+ md += `| **URL** | ${m.url} |\n`;
103
+ md += `\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted.`;
90
104
  return {
91
105
  content: [{ type: "text", text: md }],
92
106
  };
93
107
  });
94
- server.tool("delete_media", "Delete a media file by ID.", {
95
- id: z.string().describe("The media ID to delete"),
96
- }, async ({ id }) => {
97
- const result = await getClient().deleteMedia(id);
108
+ server.tool("update_media", "Rename an existing media file or move it into a folder (so it's findable later instead of re-uploading it). Only the fields you pass are changed.", {
109
+ id: z.string().describe("The media ID to update"),
110
+ name: z.string().optional().describe('New human-readable label, e.g. "pp-play5get50". Pass an empty string to clear it.'),
111
+ folder_id: z.string().optional().describe('Move the file into this folder id (from list_folders). Pass an empty string to move it to the root ("All media").'),
112
+ }, async ({ id, name, folder_id }) => {
113
+ const body = {};
114
+ if (name !== undefined)
115
+ body.name = name;
116
+ if (folder_id !== undefined)
117
+ body.folder_id = folder_id === "" ? null : folder_id;
118
+ const result = await getClient().updateMedia(id, body);
119
+ if (result.error) {
120
+ return {
121
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
122
+ };
123
+ }
124
+ const m = result.data;
125
+ return {
126
+ content: [
127
+ {
128
+ type: "text",
129
+ text: `Updated media \`${m.id}\`.\n\n| Field | Value |\n|-------|-------|\n| **Name** | ${m.name || "—"} |\n| **Folder** | ${m.folder_id ? `\`${m.folder_id}\`` : "root"} |`,
130
+ },
131
+ ],
132
+ };
133
+ });
134
+ server.tool("list_folders", "List the media-library folders in this workspace (Finder-style organization). Returns each folder's id, name, parent, and item count. Use a folder id with list_media (folder_id) or update_media (folder_id), or a folder name with upload_media (folder).", {}, async () => {
135
+ const result = await getClient().listFolders();
136
+ if (result.error) {
137
+ return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
138
+ }
139
+ const items = Array.isArray(result.data) ? result.data : result.data?.data || [];
140
+ if (!items.length) {
141
+ return { content: [{ type: "text", text: "No folders yet. Create one with create_folder." }] };
142
+ }
143
+ let md = `## Folders (${items.length})\n\n`;
144
+ md += `| Folder ID | Name | Parent | Items |\n|-----------|------|--------|-------|\n`;
145
+ for (const f of items) {
146
+ md += `| \`${f.id}\` | ${f.name} | ${f.parent_id ? `\`${f.parent_id}\`` : "—"} | ${f.item_count ?? 0} |\n`;
147
+ }
148
+ return { content: [{ type: "text", text: md }] };
149
+ });
150
+ server.tool("create_folder", "Create a media-library folder. Use it to organize assets (e.g. a 'win-graphics' folder), then upload into it with upload_media (folder) or move files with update_media (folder_id).", {
151
+ name: z.string().describe("Folder name, e.g. 'win-graphics'"),
152
+ parent_id: z.string().optional().describe("Optional parent folder id to nest under (from list_folders). Omit for a top-level folder."),
153
+ }, async ({ name, parent_id }) => {
154
+ const result = await getClient().createFolder({ name, parent_id });
155
+ if (result.error) {
156
+ return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
157
+ }
158
+ const f = result.data;
98
159
  return {
99
- content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Media deleted successfully." }],
160
+ content: [
161
+ {
162
+ type: "text",
163
+ text: `Created folder \`${f.id}\` "${f.name}". Upload into it with upload_media (folder="${f.name}").`,
164
+ },
165
+ ],
100
166
  };
101
167
  });
102
168
  }
@@ -180,7 +180,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
180
180
  3. **Schedule**: When should it be published? (Or save as draft?)
181
181
  4. **Media** (REQUIRED for some types):
182
182
  - Stories: ALWAYS require an image or video. Ask the user which image/video to use. Use list_media to show their uploaded media, or ask for an external URL.
183
- - Reels: ALWAYS require a video. Ask the user which video to use. Use list_media to find available videos.
183
+ - Reels: ALWAYS require a video (MP4/MOV) — NOT an image. Passing an image to a Reel returns a 400 validation_error. To share an image, use post_type "Post" instead. Ask the user which video to use; use list_media to find available videos.
184
184
  - Instagram posts: ALWAYS require at least one image or video.
185
185
  - TikTok posts: ALWAYS require at least one image or video.
186
186
  - Pinterest posts: ALWAYS require an image AND a board_id.
@@ -228,6 +228,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
228
228
  link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
229
229
  link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
230
230
  location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue (with a street address) to tag the post's location. Applied to single-image and carousel Instagram feed posts. To get a valid ID, call the `search_locations` tool with the place name and let the user pick. Ignored by other platforms."),
231
+ collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. Invited users get an invite in the Instagram app; once they accept, the post also appears on their profile and feed. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time with a clear error. Ignored by other platforms."),
232
+ user_tags: z.array(z.object({
233
+ username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
234
+ x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
235
+ y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
236
+ image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
237
+ })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
231
238
  pinterest: z.object({
232
239
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
233
240
  title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
@@ -257,6 +264,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
257
264
  disable_stitch: z.boolean().optional(),
258
265
  is_aigc: z.boolean().optional(),
259
266
  brand_content_toggle: z.boolean().optional(),
267
+ auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
260
268
  }).optional().describe("TikTok options"),
261
269
  x: z.object({
262
270
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
@@ -264,8 +272,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
264
272
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
265
273
  thread_parts: z.array(z.object({
266
274
  text: z.string().describe("Tweet text (≤ 280 chars)"),
267
- media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
268
- })).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."),
275
+ media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
276
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
277
+ })).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. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
269
278
  }).optional().describe("X (Twitter) options"),
270
279
  google_business: z.object({
271
280
  topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
@@ -345,7 +354,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
345
354
  2. **Channels**: Which platforms? Use list_accounts if needed.
346
355
  3. **Media** (REQUIRED for some types — same rules as create_post):
347
356
  - Stories: ALWAYS need an image or video.
348
- - Reels: ALWAYS need a video.
357
+ - Reels: ALWAYS need a video (MP4/MOV), NOT an image — passing an image returns a 400 validation_error. Use post_type "Post" to share an image.
349
358
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
350
359
  - Pinterest: ALWAYS need an image + board_id.
351
360
  - Ask the user which media to use. Use list_media to show options.
@@ -366,6 +375,14 @@ Do NOT call without required media — it will fail.`, {
366
375
  z.record(z.string(), z.array(z.string())),
367
376
  ]).optional().describe("External image/video URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
368
377
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
378
+ location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue to tag the post's location. Applied to single-image and carousel Instagram feed posts. Use the `search_locations` tool to find a valid ID. Ignored by other platforms."),
379
+ collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms."),
380
+ user_tags: z.array(z.object({
381
+ username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
382
+ x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
383
+ y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
384
+ image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
385
+ })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
369
386
  pinterest: z.object({
370
387
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
371
388
  title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
@@ -387,8 +404,9 @@ Do NOT call without required media — it will fail.`, {
387
404
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
388
405
  thread_parts: z.array(z.object({
389
406
  text: z.string().describe("Tweet text (≤ 280 chars)"),
390
- media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
391
- })).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."),
407
+ media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
408
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
409
+ })).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. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
392
410
  }).optional().describe("X (Twitter) options"),
393
411
  google_business: z.object({
394
412
  topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
@@ -460,6 +478,13 @@ Do NOT call without required media — it will fail.`, {
460
478
  z.record(z.string(), z.array(z.string())),
461
479
  ]).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."),
462
480
  location_id: z.string().optional().describe("Instagram only. Facebook Place/Page ID to tag the post's location with. Send an empty string to clear an existing location tag. Ignored by other platforms."),
481
+ collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors. Replaces the existing collaborator list. Send an empty array to clear collaborators. Works on image, carousel, and reel posts — NOT Stories. Ignored by other platforms."),
482
+ user_tags: z.array(z.object({
483
+ username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
484
+ x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
485
+ y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
486
+ image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
487
+ })).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
463
488
  youtube: z.object({
464
489
  title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
465
490
  tags: z.array(z.string()).optional(),
@@ -489,6 +514,7 @@ Do NOT call without required media — it will fail.`, {
489
514
  disable_stitch: z.boolean().optional(),
490
515
  is_aigc: z.boolean().optional(),
491
516
  brand_content_toggle: z.boolean().optional(),
517
+ auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
492
518
  }).optional().describe("TikTok options"),
493
519
  x: z.object({
494
520
  reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
@@ -496,8 +522,9 @@ Do NOT call without required media — it will fail.`, {
496
522
  made_with_ai: z.boolean().optional(),
497
523
  thread_parts: z.array(z.object({
498
524
  text: z.string().describe("Tweet text (≤ 280 chars)"),
499
- media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
500
- })).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."),
525
+ media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
526
+ media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
527
+ })).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 (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
501
528
  }).optional().describe("X (Twitter) options"),
502
529
  google_business: z.object({
503
530
  topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
@@ -1,25 +1,67 @@
1
1
  import { z } from "zod";
2
+ /** Fetch each workspace's name + id so we can match a switch request to it. */
3
+ async function describeWorkspaces(workspaceClients, sessionState) {
4
+ return Promise.all(workspaceClients.map(async (wc, index) => {
5
+ try {
6
+ const result = await wc.client.listAccounts();
7
+ return {
8
+ index,
9
+ workspace_id: result.workspace_id != null ? String(result.workspace_id) : null,
10
+ workspace_name: result.workspace_name || `Workspace ${index + 1}`,
11
+ accounts_count: Array.isArray(result.data) ? result.data.length : 0,
12
+ active: index === sessionState.activeIndex,
13
+ };
14
+ }
15
+ catch {
16
+ return {
17
+ index,
18
+ workspace_id: null,
19
+ workspace_name: `Workspace ${index + 1}`,
20
+ accounts_count: 0,
21
+ active: index === sessionState.activeIndex,
22
+ };
23
+ }
24
+ }));
25
+ }
26
+ /**
27
+ * Resolve a user-supplied workspace reference (name, id, or positional number)
28
+ * to an index. Order of precedence puts stable identity first so ordering can
29
+ * never send work to the wrong workspace:
30
+ * 1. exact name (case-insensitive)
31
+ * 2. exact workspace id
32
+ * 3. unique partial name match (case-insensitive)
33
+ * 4. positional number fallback (1-based) for back-compat
34
+ * Returns { index } on success, { ambiguous } when a name matches several, or
35
+ * { notFound: true }.
36
+ */
37
+ function resolveWorkspaceIndex(entries, query) {
38
+ const q = String(query ?? "").trim();
39
+ if (!q)
40
+ return { notFound: true };
41
+ const qLower = q.toLowerCase();
42
+ const exactName = entries.filter((e) => e.workspace_name.toLowerCase() === qLower);
43
+ if (exactName.length === 1)
44
+ return { index: exactName[0].index };
45
+ if (exactName.length > 1)
46
+ return { ambiguous: exactName };
47
+ const byId = entries.filter((e) => e.workspace_id === q);
48
+ if (byId.length === 1)
49
+ return { index: byId[0].index };
50
+ const partial = entries.filter((e) => e.workspace_name.toLowerCase().includes(qLower));
51
+ if (partial.length === 1)
52
+ return { index: partial[0].index };
53
+ if (partial.length > 1)
54
+ return { ambiguous: partial };
55
+ if (/^\d+$/.test(q)) {
56
+ const idx = parseInt(q, 10) - 1;
57
+ if (idx >= 0 && idx < entries.length)
58
+ return { index: idx };
59
+ }
60
+ return { notFound: true };
61
+ }
2
62
  export function registerWorkspaceTools(server, workspaceClients, sessionState) {
3
63
  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
- const workspaces = await Promise.all(workspaceClients.map(async (wc, index) => {
5
- try {
6
- const result = await wc.client.listAccounts();
7
- return {
8
- index,
9
- workspace_name: result.workspace_name || `Workspace ${index + 1}`,
10
- active: index === sessionState.activeIndex,
11
- accounts_count: (Array.isArray(result.data) ? result.data : []).length,
12
- };
13
- }
14
- catch {
15
- return {
16
- index,
17
- workspace_name: `Workspace ${index + 1}`,
18
- active: index === sessionState.activeIndex,
19
- accounts_count: 0,
20
- };
21
- }
22
- }));
64
+ const workspaces = await describeWorkspaces(workspaceClients, sessionState);
23
65
  if (workspaces.length === 1) {
24
66
  const ws = workspaces[0];
25
67
  return {
@@ -30,40 +72,48 @@ export function registerWorkspaceTools(server, workspaceClients, sessionState) {
30
72
  };
31
73
  }
32
74
  let md = `## Workspaces (${workspaces.length})\n\n`;
33
- md += `| # | Workspace | Accounts | Status |\n`;
34
- md += `|---|-----------|----------|--------|\n`;
75
+ md += `| Workspace | Accounts | Status |\n`;
76
+ md += `|-----------|----------|--------|\n`;
35
77
  for (const ws of workspaces) {
36
78
  const status = ws.active ? "**Active**" : "";
37
- md += `| ${ws.index + 1} | ${ws.workspace_name} | ${ws.accounts_count} | ${status} |\n`;
79
+ md += `| ${ws.workspace_name} | ${ws.accounts_count} | ${status} |\n`;
38
80
  }
39
- md += `\nUse **switch_workspace** with the workspace number to change the active workspace.`;
81
+ md += `\nUse **switch_workspace** with the workspace **name** to change the active workspace (e.g. \`switch_workspace workspace: "${workspaces[0].workspace_name}"\`).`;
40
82
  return { content: [{ type: "text", text: md }] };
41
83
  });
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
- workspace_number: z.string().describe("The workspace number from list_workspaces (1, 2, 3, etc.)"),
44
- }, async ({ workspace_number }) => {
45
- const index = parseInt(workspace_number, 10) - 1;
46
- if (isNaN(index) || index < 0 || index >= workspaceClients.length) {
84
+ server.tool("switch_workspace", "Switch the active workspace by NAME. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace. Pass the workspace name exactly as shown in list_workspaces (its id or list position also work, but the name is preferred and unambiguous). 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.", {
85
+ workspace: z
86
+ .string()
87
+ .describe('The workspace to switch to: its name (preferred, e.g. "Daily Edge Sports"), or its id/list number.'),
88
+ }, async ({ workspace }) => {
89
+ const entries = await describeWorkspaces(workspaceClients, sessionState);
90
+ const resolved = resolveWorkspaceIndex(entries, workspace);
91
+ if ("ambiguous" in resolved) {
92
+ const names = resolved.ambiguous
93
+ .map((e) => `- ${e.workspace_name}${e.workspace_id ? ` (id ${e.workspace_id})` : ""}`)
94
+ .join("\n");
47
95
  return {
48
96
  content: [{
49
97
  type: "text",
50
- text: `Error: Invalid workspace number. Use list_workspaces to see available options (1-${workspaceClients.length}).`,
98
+ text: `"${workspace}" matches more than one workspace:\n\n${names}\n\nPlease specify the exact name or id.`,
51
99
  }],
52
100
  };
53
101
  }
54
- sessionState.activeIndex = index;
55
- const wc = workspaceClients[index];
56
- let workspaceName = `Workspace ${index + 1}`;
57
- try {
58
- const result = await wc.client.listAccounts();
59
- if (result.workspace_name)
60
- workspaceName = result.workspace_name;
102
+ if ("notFound" in resolved) {
103
+ const avail = entries.map((e) => e.workspace_name).join(", ");
104
+ return {
105
+ content: [{
106
+ type: "text",
107
+ text: `Error: No workspace matches "${workspace}". Available workspaces: ${avail}. Use list_workspaces to see them.`,
108
+ }],
109
+ };
61
110
  }
62
- catch { }
111
+ sessionState.activeIndex = resolved.index;
112
+ const ws = entries[resolved.index];
63
113
  return {
64
114
  content: [{
65
115
  type: "text",
66
- text: `Switched to **${workspaceName}**. All subsequent calls will use this workspace.`,
116
+ text: `Switched to **${ws.workspace_name}**. All subsequent calls will use this workspace.`,
67
117
  }],
68
118
  };
69
119
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.4.1",
3
+ "version": "1.7.0",
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",
@@ -36,5 +36,10 @@
36
36
  "devDependencies": {
37
37
  "@types/node": "^25.3.3",
38
38
  "typescript": "^5.8.2"
39
+ },
40
+ "overrides": {
41
+ "zod": "4.4.0",
42
+ "fast-uri": "3.1.1",
43
+ "hono": "4.12.18"
39
44
  }
40
45
  }