@omnisocials/mcp-server 1.3.8 → 1.4.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
@@ -126,6 +126,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
126
126
  | `update_post` | Update a draft or scheduled post |
127
127
  | `delete_post` | Delete a post |
128
128
  | `publish_post` | Publish a draft or scheduled post now |
129
+ | `search_locations` | Find an Instagram location to tag (returns place IDs for `location_id`) |
129
130
 
130
131
  ### Media (3 tools)
131
132
 
package/build/client.d.ts CHANGED
@@ -49,11 +49,17 @@ export declare class OmniSocialsClient {
49
49
  media_urls?: string[] | Record<string, string[]>;
50
50
  type?: string;
51
51
  source?: string;
52
+ link_url?: string;
53
+ link_title?: string;
54
+ link_description?: string;
55
+ link_thumbnail_url?: string;
56
+ location_id?: string;
52
57
  pinterest?: Record<string, unknown>;
53
58
  youtube?: Record<string, unknown>;
54
59
  instagram?: Record<string, unknown>;
55
60
  tiktok?: Record<string, unknown>;
56
61
  x?: XPostOptions;
62
+ google_business?: Record<string, unknown>;
57
63
  }): Promise<ApiResponse<unknown>>;
58
64
  createAndPublishPost(data: {
59
65
  content: string | Record<string, string>;
@@ -62,11 +68,17 @@ export declare class OmniSocialsClient {
62
68
  media_urls?: string[] | Record<string, string[]>;
63
69
  type?: string;
64
70
  source?: string;
71
+ link_url?: string;
72
+ link_title?: string;
73
+ link_description?: string;
74
+ link_thumbnail_url?: string;
75
+ location_id?: string;
65
76
  pinterest?: Record<string, unknown>;
66
77
  youtube?: Record<string, unknown>;
67
78
  instagram?: Record<string, unknown>;
68
79
  tiktok?: Record<string, unknown>;
69
80
  x?: XPostOptions;
81
+ google_business?: Record<string, unknown>;
70
82
  }): Promise<ApiResponse<unknown>>;
71
83
  updatePost(id: string, data: {
72
84
  content?: string | Record<string, string>;
@@ -75,14 +87,18 @@ export declare class OmniSocialsClient {
75
87
  media_ids?: string[] | Record<string, string[]>;
76
88
  media_urls?: string[] | Record<string, string[]>;
77
89
  type?: string;
90
+ location_id?: string;
78
91
  pinterest?: Record<string, unknown>;
79
92
  youtube?: Record<string, unknown>;
80
93
  instagram?: Record<string, unknown>;
81
94
  tiktok?: Record<string, unknown>;
82
95
  x?: XPostOptionsUpdate;
96
+ google_business?: Record<string, unknown>;
83
97
  }): Promise<ApiResponse<unknown>>;
84
98
  deletePost(id: string): Promise<ApiResponse<unknown>>;
85
99
  publishPost(id: string): Promise<ApiResponse<unknown>>;
100
+ searchLocations(query: string): Promise<ApiResponse<unknown>>;
101
+ validateLocation(id: string): Promise<ApiResponse<unknown>>;
86
102
  listMedia(params?: {
87
103
  limit?: string;
88
104
  offset?: string;
package/build/client.js CHANGED
@@ -150,6 +150,13 @@ export class OmniSocialsClient {
150
150
  async publishPost(id) {
151
151
  return this.request("POST", `/posts/${id}/publish`);
152
152
  }
153
+ // Locations (Instagram place tagging)
154
+ async searchLocations(query) {
155
+ return this.request("GET", "/locations/search", undefined, { q: query });
156
+ }
157
+ async validateLocation(id) {
158
+ return this.request("GET", "/locations/validate", undefined, { id });
159
+ }
153
160
  // Media
154
161
  async listMedia(params) {
155
162
  return this.request("GET", "/media", undefined, params);
@@ -5,7 +5,7 @@ export function registerAccountTools(server, getClient) {
5
5
  const result = await getClient().listAccounts();
6
6
  if (result.error) {
7
7
  return {
8
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
8
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
9
9
  };
10
10
  }
11
11
  const accounts = Array.isArray(result.data) ? result.data : result.data?.accounts || [];
@@ -23,8 +23,9 @@ export function registerAccountTools(server, getClient) {
23
23
  const name = a.username ? `@${a.username}` : a.display_name || "—";
24
24
  const types = (a.content_types || []).join(", ");
25
25
  const platform = capitalize(a.platform);
26
- const sub = a.platform_details?.subscription_type && a.platform_details.subscription_type !== "None"
27
- ? ` (${a.platform_details.subscription_type})`
26
+ const subType = a.platform_details?.subscription_type;
27
+ const sub = subType === "Premium" || subType === "PremiumPlus"
28
+ ? ` (${subType})`
28
29
  : "";
29
30
  const statusCell = a.needs_reconnect
30
31
  ? `⚠️ needs_reconnect`
@@ -66,7 +67,7 @@ export function registerAccountTools(server, getClient) {
66
67
  const result = await getClient().getAccount(id);
67
68
  if (result.error) {
68
69
  return {
69
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
70
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
70
71
  };
71
72
  }
72
73
  const a = result.data;
@@ -88,8 +89,9 @@ export function registerAccountTools(server, getClient) {
88
89
  md += `| **Reconnect reason** | ${a.reauth_reason} |\n`;
89
90
  }
90
91
  md += `| **Connected** | ${a.connected_at ? new Date(a.connected_at).toLocaleDateString() : "—"} |\n`;
91
- if (a.platform_details?.subscription_type) {
92
- md += `| **Subscription** | ${a.platform_details.subscription_type} |\n`;
92
+ const getSubType = a.platform_details?.subscription_type;
93
+ if (getSubType === "Premium" || getSubType === "PremiumPlus") {
94
+ md += `| **Subscription** | ${getSubType} |\n`;
93
95
  }
94
96
  if (a.boards?.length) {
95
97
  md += `\n### Pinterest Boards\n\n`;
@@ -7,7 +7,7 @@ export function registerAnalyticsTools(server, getClient) {
7
7
  const result = await getClient().getPostAnalytics(post_id);
8
8
  if (result.error) {
9
9
  return {
10
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
10
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
11
11
  };
12
12
  }
13
13
  const d = result.data;
@@ -73,7 +73,7 @@ export function registerAnalyticsTools(server, getClient) {
73
73
  const result = await getClient().getAnalyticsOverview(params);
74
74
  if (result.error) {
75
75
  return {
76
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
76
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
77
77
  };
78
78
  }
79
79
  const d = result.data;
@@ -118,7 +118,7 @@ export function registerAnalyticsTools(server, getClient) {
118
118
  const result = await getClient().getAccountAnalytics(params);
119
119
  if (result.error) {
120
120
  return {
121
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
121
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
122
122
  };
123
123
  }
124
124
  const accounts = result.data || [];
@@ -8,7 +8,7 @@ export function registerMediaTools(server, getClient) {
8
8
  const result = await getClient().listMedia(params);
9
9
  if (result.error) {
10
10
  return {
11
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
11
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
12
12
  };
13
13
  }
14
14
  const items = Array.isArray(result.data) ? result.data : result.data?.media || [];
@@ -75,7 +75,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
75
75
  }
76
76
  if (result.error) {
77
77
  return {
78
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
78
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
79
79
  };
80
80
  }
81
81
  const m = result.data;
@@ -96,7 +96,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
96
96
  }, async ({ id }) => {
97
97
  const result = await getClient().deleteMedia(id);
98
98
  return {
99
- content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Media deleted successfully." }],
99
+ content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Media deleted successfully." }],
100
100
  };
101
101
  });
102
102
  }
@@ -61,7 +61,7 @@ export function registerPostTools(server, getClient) {
61
61
  const result = await getClient().listPosts(params);
62
62
  if (result.error) {
63
63
  return {
64
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
64
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
65
65
  };
66
66
  }
67
67
  const posts = Array.isArray(result.data) ? result.data : result.data?.posts || [];
@@ -106,7 +106,7 @@ export function registerPostTools(server, getClient) {
106
106
  const result = await getClient().getPost(id);
107
107
  if (result.error) {
108
108
  return {
109
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
109
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
110
110
  };
111
111
  }
112
112
  const p = result.data;
@@ -185,6 +185,19 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
185
185
  - TikTok posts: ALWAYS require at least one image or video.
186
186
  - Pinterest posts: ALWAYS require an image AND a board_id.
187
187
  - Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
188
+
189
+ **Per-platform max media items (enforced at submit, returns 400 validation_error if exceeded):**
190
+ - Bluesky, X, Mastodon — max **4** items per post
191
+ - Instagram, Threads — max **10** items per carousel
192
+ - TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
193
+ - Pinterest carousel — 2–5 images, all same aspect ratio
194
+
195
+ **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."):**
196
+ - 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)
197
+ - Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
198
+ 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.
199
+
200
+ When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
188
201
  5. **Platform-specific options** (ask only when relevant):
189
202
  - **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:
190
203
  1. Call \`get_account\` on the Pinterest account ID — the response includes a \`Pinterest Boards\` table with each board's name and ID.
@@ -199,7 +212,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
199
212
 
200
213
  **When the user shares an image in chat but you cannot upload it** (e.g. no public URL available): save the post as a draft with the caption, then tell the user: "I saved your post as a draft in OmniSocials. Open it there to add your image and schedule it when ready." Include a link to https://app.omnisocials.com. Do NOT tell the user it's impossible — always save the draft so their caption isn't lost.`, {
201
214
  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."),
202
- channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
215
+ channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
203
216
  scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing"),
204
217
  media_ids: z.union([
205
218
  z.array(z.string()),
@@ -210,13 +223,18 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
210
223
  z.record(z.string(), z.array(z.string())),
211
224
  ]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Max 10 total. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media."),
212
225
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
226
+ link_url: z.string().optional().describe("URL to share as a rich preview card on platforms that support link-share posts (LinkedIn and Facebook). The URL renders as a tile with thumbnail / title / description instead of plain text. Ignored on platforms that don't support link shares, and ignored on posts that already have media attached (media wins)."),
227
+ link_title: z.string().optional().describe("Optional title for the link-share preview. LinkedIn uses this when set; Facebook ignores it and fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."),
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
+ 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
+ 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."),
213
231
  pinterest: z.object({
214
232
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
215
- title: z.string().optional().describe("Pin title (max 100 characters)"),
233
+ 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."),
216
234
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
217
235
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
218
236
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
219
- }).optional().describe("Pinterest-specific options"),
237
+ }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
220
238
  youtube: z.object({
221
239
  title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
222
240
  tags: z.array(z.string()).optional(),
@@ -249,11 +267,32 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
249
267
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
250
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."),
251
269
  }).optional().describe("X (Twitter) options"),
270
+ google_business: z.object({
271
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
272
+ cta: z.object({
273
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]).describe("Button rendered under the caption. Phone numbers belong on a CALL button and links on a LEARN_MORE / BOOK / SHOP button — they're rejected if placed in the caption text."),
274
+ url: z.string().optional().describe("Required for every actionType except CALL. Must be http:// or https://. CALL uses the location's phone number from the business profile."),
275
+ }).optional().describe("Optional call-to-action button."),
276
+ event: z.object({
277
+ title: z.string().max(58).describe("Event title (max 58 chars)."),
278
+ schedule: z.object({
279
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
280
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
281
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
282
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
283
+ }).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
284
+ }).optional().describe("Required when topic_type is EVENT."),
285
+ offer: z.object({
286
+ couponCode: z.string().max(58).optional(),
287
+ redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
288
+ termsConditions: z.string().max(4000).optional(),
289
+ }).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
290
+ }).optional().describe("Google Business Profile options. Use to publish EVENT or OFFER posts, attach a CTA button, or both. Shape mirrors Google's LocalPost resource (see https://developers.google.com/my-business/reference/rest/v4/accounts.locations.localPosts#LocalPost).\n\nGoogle Business caption rules (enforced at scheduling — text that violates these will return a `validation_error` 400 before the post is saved):\n • Phone numbers in the caption are rejected — use a CALL button instead.\n • Inline URLs / bare domains / emails are rejected — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons instead.\n • Caption max 1500 characters.\n • Media is optional (text-only posts are allowed). If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • The workspace must have a Google Business location selected (Settings → Organisation → Workspaces → Google Business) before scheduling — otherwise returns a 400."),
252
291
  }, async (params) => {
253
292
  const result = await getClient().createPost({ ...params, source: "mcp" });
254
293
  if (result.error) {
255
294
  return {
256
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
295
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
257
296
  };
258
297
  }
259
298
  const p = result.data;
@@ -310,12 +349,14 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
310
349
  - Instagram/TikTok posts: ALWAYS need at least one image or video.
311
350
  - Pinterest: ALWAYS need an image + board_id.
312
351
  - Ask the user which media to use. Use list_media to show options.
352
+ - **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos with no photo/video mix, Pinterest carousel 2–5 same-ratio images.
353
+ - **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.
313
354
  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.
314
355
  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.
315
356
 
316
357
  Do NOT call without required media — it will fail.`, {
317
358
  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."),
318
- channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
359
+ channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
319
360
  media_ids: z.union([
320
361
  z.array(z.string()),
321
362
  z.record(z.string(), z.array(z.string())),
@@ -327,11 +368,11 @@ Do NOT call without required media — it will fail.`, {
327
368
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
328
369
  pinterest: z.object({
329
370
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
330
- title: z.string().optional().describe("Pin title (max 100 characters)"),
371
+ 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."),
331
372
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
332
373
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
333
374
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
334
- }).optional().describe("Pinterest-specific options"),
375
+ }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
335
376
  youtube: z.object({
336
377
  title: z.string().optional().describe("Short title shown on YouTube."),
337
378
  tags: z.array(z.string()).optional(),
@@ -349,11 +390,32 @@ Do NOT call without required media — it will fail.`, {
349
390
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
350
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."),
351
392
  }).optional().describe("X (Twitter) options"),
393
+ google_business: z.object({
394
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
395
+ cta: z.object({
396
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
397
+ url: z.string().optional(),
398
+ }).optional(),
399
+ event: z.object({
400
+ title: z.string().max(58),
401
+ schedule: z.object({
402
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
403
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
404
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
405
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
406
+ }).optional(),
407
+ }).optional(),
408
+ offer: z.object({
409
+ couponCode: z.string().max(58).optional(),
410
+ redeemOnlineUrl: z.string().optional(),
411
+ termsConditions: z.string().max(4000).optional(),
412
+ }).optional(),
413
+ }).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
352
414
  }, async (params) => {
353
415
  const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
354
416
  if (result.error) {
355
417
  return {
356
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
418
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
357
419
  };
358
420
  }
359
421
  const p = result.data;
@@ -388,7 +450,7 @@ Do NOT call without required media — it will fail.`, {
388
450
  id: z.string().describe("The post ID to update"),
389
451
  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\" }."),
390
452
  scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
391
- channels: z.array(z.string()).optional().describe("Updated channel IDs. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
453
+ channels: z.array(z.string()).optional().describe("Updated channel IDs. Bare platform name or composite `\"<workspace_id>_<platform>\"`; always source from list_accounts for this API key (unknown or mismatched workspace prefixes are rejected as unknown accounts). Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
392
454
  media_ids: z.union([
393
455
  z.array(z.string()),
394
456
  z.record(z.string(), z.array(z.string())),
@@ -397,6 +459,7 @@ Do NOT call without required media — it will fail.`, {
397
459
  z.array(z.string()),
398
460
  z.record(z.string(), z.array(z.string())),
399
461
  ]).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
+ 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."),
400
463
  youtube: z.object({
401
464
  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)."),
402
465
  tags: z.array(z.string()).optional(),
@@ -408,11 +471,11 @@ Do NOT call without required media — it will fail.`, {
408
471
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
409
472
  pinterest: z.object({
410
473
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
411
- title: z.string().optional().describe("Pin title (max 100 characters)"),
474
+ 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."),
412
475
  link: z.string().optional().describe("Destination URL the pin clicks through to"),
413
476
  video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
414
477
  alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
415
- }).optional().describe("Pinterest-specific options"),
478
+ }).optional().describe("Pinterest-specific options. Attach 2–5 images via media_urls.pinterest (or default) to publish a single carousel pin instead of separate pins. Carousel slides MUST share the same aspect ratio (1% tolerance) — the API returns 400 validation_error with `mismatched_slides: [n, ...]` if you pass mixed-ratio images and try to schedule or publish. Drafts are exempt so you can iterate."),
416
479
  instagram: z.object({
417
480
  share_to_feed: z.boolean().optional(),
418
481
  thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
@@ -436,11 +499,32 @@ Do NOT call without required media — it will fail.`, {
436
499
  media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
437
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."),
438
501
  }).optional().describe("X (Twitter) options"),
502
+ google_business: z.object({
503
+ topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
504
+ cta: z.object({
505
+ actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
506
+ url: z.string().optional(),
507
+ }).optional(),
508
+ event: z.object({
509
+ title: z.string().max(58),
510
+ schedule: z.object({
511
+ startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
512
+ startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
513
+ endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
514
+ endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
515
+ }).optional(),
516
+ }).optional(),
517
+ offer: z.object({
518
+ couponCode: z.string().max(58).optional(),
519
+ redeemOnlineUrl: z.string().optional(),
520
+ termsConditions: z.string().max(4000).optional(),
521
+ }).optional(),
522
+ }).optional().describe("Google Business Profile options (STANDARD/EVENT/OFFER + optional CTA). Same shape as create_post.\n\nGoogle Business caption rules (enforced at scheduling — text that violates these returns a `validation_error` 400):\n • No phone numbers in the caption — use a CALL button instead.\n • No inline URLs / bare domains / emails — use LEARN_MORE / BOOK / SHOP / SIGN_UP / ORDER buttons.\n • Caption max 1500 characters.\n • Media is optional. If attached: exactly one JPEG/PNG/WebP image (no video, no carousels).\n • Workspace must have a Google Business location selected before scheduling — otherwise returns a 400."),
439
523
  }, async ({ id, ...data }) => {
440
524
  const result = await getClient().updatePost(id, data);
441
525
  if (result.error) {
442
526
  return {
443
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
527
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
444
528
  };
445
529
  }
446
530
  const p = result.data;
@@ -464,7 +548,7 @@ Do NOT call without required media — it will fail.`, {
464
548
  }, async ({ id }) => {
465
549
  const result = await getClient().deletePost(id);
466
550
  return {
467
- content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Post deleted successfully." }],
551
+ content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
468
552
  };
469
553
  });
470
554
  server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing.
@@ -475,7 +559,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
475
559
  const result = await getClient().publishPost(id);
476
560
  if (result.error) {
477
561
  return {
478
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
562
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
479
563
  };
480
564
  }
481
565
  const p = result.data;
@@ -488,4 +572,30 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
488
572
  content: [{ type: "text", text: md }],
489
573
  };
490
574
  });
575
+ server.tool("search_locations", `Search for an Instagram location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
576
+
577
+ Flow: call this with the place name → present the options to the user → once they pick, pass that result's \`id\` as \`location_id\` on create_post / create_and_publish_post / update_post. Only Instagram supports location tagging.
578
+
579
+ Notes:
580
+ - Use a SPECIFIC venue name. Searching a broad brand ("Starbucks") returns individual store locations, not the brand page.
581
+ - If the user already gives you a numeric Facebook Place ID, you can pass it straight to create_post as \`location_id\`; it's validated at publish time and an invalid one returns a clear error.
582
+ - If results come back empty with a permission note, the workspace's Facebook app can't search arbitrary places — tell the user to use their own business Page's ID.`, {
583
+ query: z
584
+ .string()
585
+ .describe("Place name to search (min 2 chars) — a dealership, café, venue, etc."),
586
+ }, async ({ query }) => {
587
+ const result = await getClient().searchLocations(query);
588
+ const places = result?.data || [];
589
+ if (!places.length) {
590
+ const why = result?.error ||
591
+ `No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
592
+ return { content: [{ type: "text", text: why }] };
593
+ }
594
+ let md = `## Locations matching "${query}"\n\nAsk the user which one, then use its ID as \`location_id\`:\n\n`;
595
+ md += `| # | Name | Address | location_id |\n|---|------|---------|-------------|\n`;
596
+ places.forEach((p, i) => {
597
+ md += `| ${i + 1} | ${p.name} | ${p.address || "—"} | \`${p.id}\` |\n`;
598
+ });
599
+ return { content: [{ type: "text", text: md }] };
600
+ });
491
601
  }
@@ -5,7 +5,7 @@ export function registerWebhookTools(server, getClient) {
5
5
  const result = await getClient().listWebhooks();
6
6
  if (result.error) {
7
7
  return {
8
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
8
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
9
9
  };
10
10
  }
11
11
  const webhooks = Array.isArray(result.data) ? result.data : result.data?.webhooks || [];
@@ -35,7 +35,7 @@ export function registerWebhookTools(server, getClient) {
35
35
  const result = await getClient().createWebhook(params);
36
36
  if (result.error) {
37
37
  return {
38
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
38
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
39
39
  };
40
40
  }
41
41
  const w = result.data;
@@ -60,7 +60,7 @@ export function registerWebhookTools(server, getClient) {
60
60
  const result = await getClient().getWebhook(id);
61
61
  if (result.error) {
62
62
  return {
63
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
63
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
64
64
  };
65
65
  }
66
66
  const w = result.data;
@@ -87,7 +87,7 @@ export function registerWebhookTools(server, getClient) {
87
87
  const result = await getClient().updateWebhook(id, data);
88
88
  if (result.error) {
89
89
  return {
90
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
90
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
91
91
  };
92
92
  }
93
93
  const w = result.data;
@@ -107,7 +107,7 @@ export function registerWebhookTools(server, getClient) {
107
107
  }, async ({ id }) => {
108
108
  const result = await getClient().deleteWebhook(id);
109
109
  return {
110
- content: [{ type: "text", text: result.error ? `Error: ${result.error.message}` : "Webhook deleted successfully." }],
110
+ content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Webhook deleted successfully." }],
111
111
  };
112
112
  });
113
113
  server.tool("rotate_webhook_secret", "Rotate the signing secret for a webhook. The new secret will only be shown once.", {
@@ -116,7 +116,7 @@ export function registerWebhookTools(server, getClient) {
116
116
  const result = await getClient().rotateWebhookSecret(id);
117
117
  if (result.error) {
118
118
  return {
119
- content: [{ type: "text", text: `Error: ${result.error.message}` }],
119
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
120
120
  };
121
121
  }
122
122
  const w = result.data;
package/build/types.d.ts CHANGED
@@ -14,6 +14,14 @@ export interface Post {
14
14
  channels: string[];
15
15
  media: string[];
16
16
  created_at: string;
17
+ /**
18
+ * Per-platform user-friendly error messages, keyed by platform identifier
19
+ * (facebook, instagram, linkedin, linkedin_page, youtube, tiktok, pinterest,
20
+ * x, threads, bluesky, mastodon, google_business). Populated when `status`
21
+ * is `failed` or `warning`. Only failed platforms appear. `null` while the
22
+ * post is still draft/scheduled/processing or every platform succeeded.
23
+ */
24
+ errors?: Record<string, string> | null;
17
25
  x?: {
18
26
  reply_settings?: string;
19
27
  paid_partnership?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.3.8",
3
+ "version": "1.4.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",