@omnisocials/mcp-server 1.19.0 → 1.20.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/README.md +5 -0
- package/build/client.d.ts +94 -100
- package/build/client.js +21 -4
- package/build/index.js +1 -1
- package/build/metrics.d.ts +24 -0
- package/build/metrics.js +111 -0
- package/build/tools/accounts.js +2 -2
- package/build/tools/analytics.js +49 -27
- package/build/tools/hashtag-sets.js +11 -1
- package/build/tools/inbox.js +4 -3
- package/build/tools/media.js +19 -3
- package/build/tools/posts.js +196 -206
- package/build/tools/webhooks.js +21 -1
- package/build/types.d.ts +295 -31
- package/package.json +1 -1
package/build/tools/posts.js
CHANGED
|
@@ -24,6 +24,30 @@ const mediaIdEntry = z.union([
|
|
|
24
24
|
alt: z.string().max(1500).optional().describe(ALT_TEXT_DESCRIBE),
|
|
25
25
|
}),
|
|
26
26
|
]).describe("Library media ID — a plain string, or { id, alt } to attach an accessibility description (alt text).");
|
|
27
|
+
// Pinterest carousels cap at 5 images (Pinterest v5 `multiple_image_urls`);
|
|
28
|
+
// videos are exempt — they publish as single video pins. Mirrors the
|
|
29
|
+
// server-side create-time check in shared/post-validation with the identical
|
|
30
|
+
// message; failing here just saves the agent an API round-trip. Only the
|
|
31
|
+
// per-platform object form is checked — with a flat array this field alone
|
|
32
|
+
// can't tell whether Pinterest is selected, so the server covers that case.
|
|
33
|
+
const VIDEO_URL_RE = /\.(mp4|mov|webm|m4v|avi|mkv)(\?|$)/i;
|
|
34
|
+
const pinterestImageCap = (value, ctx) => {
|
|
35
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
36
|
+
return;
|
|
37
|
+
const entries = value.pinterest;
|
|
38
|
+
if (!Array.isArray(entries))
|
|
39
|
+
return;
|
|
40
|
+
const imageCount = entries.filter((entry) => {
|
|
41
|
+
const url = typeof entry === "string" ? entry : entry?.url;
|
|
42
|
+
return !(typeof url === "string" && VIDEO_URL_RE.test(url));
|
|
43
|
+
}).length;
|
|
44
|
+
if (imageCount > 5) {
|
|
45
|
+
ctx.addIssue({
|
|
46
|
+
code: z.ZodIssueCode.custom,
|
|
47
|
+
message: "Pinterest carousel pins support a maximum of 5 images. Please remove some images and try again.",
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
};
|
|
27
51
|
// Reusable per-platform option objects for the "first comment" feature — text
|
|
28
52
|
// auto-posted as a comment on the post right after it publishes (hashtags out
|
|
29
53
|
// of the caption, "link in first comment", etc.). Only platforms with a
|
|
@@ -59,6 +83,76 @@ const LINKEDIN_PAGE_OPTIONS = z
|
|
|
59
83
|
})
|
|
60
84
|
.optional()
|
|
61
85
|
.describe("LinkedIn Company Page options");
|
|
86
|
+
// Per-platform option schemas shared verbatim by create_post,
|
|
87
|
+
// create_and_publish_post and update_post (they were byte-identical in all
|
|
88
|
+
// three). Platform blocks that genuinely differ per tool (instagram, youtube,
|
|
89
|
+
// google_business, and the thread_parts wrappers with their per-tool
|
|
90
|
+
// nullability/descriptions) stay inline in each tool.
|
|
91
|
+
const TIKTOK_OPTIONS = z.object({
|
|
92
|
+
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
93
|
+
disable_comment: z.boolean().optional(),
|
|
94
|
+
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
95
|
+
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
96
|
+
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
97
|
+
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
98
|
+
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
99
|
+
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
100
|
+
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
101
|
+
}).optional().describe("TikTok options");
|
|
102
|
+
const PINTEREST_OPTIONS = z.object({
|
|
103
|
+
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
104
|
+
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."),
|
|
105
|
+
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
106
|
+
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
107
|
+
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
108
|
+
}).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. More than 5 images is rejected with a 400 validation_error at create/schedule time — carousels hard-cap at 5. 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.");
|
|
109
|
+
// One Instagram photo tag. The wrapping z.array(...).describe(...) stays
|
|
110
|
+
// per-tool because update_post documents replace/clear semantics.
|
|
111
|
+
const USER_TAG_ENTRY = z.object({
|
|
112
|
+
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
113
|
+
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
114
|
+
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
115
|
+
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
116
|
+
});
|
|
117
|
+
// Inner thread-part schemas for the chained-thread platforms. The wrapping
|
|
118
|
+
// z.array(...).min(2).max(25)[.nullable()].optional().describe(...) stays
|
|
119
|
+
// per-tool (update_post allows null to clear the thread).
|
|
120
|
+
const X_THREAD_PART = z.object({
|
|
121
|
+
text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
|
|
122
|
+
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
|
|
123
|
+
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
|
|
124
|
+
});
|
|
125
|
+
const BLUESKY_THREAD_PART = z.object({
|
|
126
|
+
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
127
|
+
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
128
|
+
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
129
|
+
});
|
|
130
|
+
const MASTODON_THREAD_PART = z.object({
|
|
131
|
+
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
132
|
+
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
133
|
+
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
134
|
+
});
|
|
135
|
+
const GOOGLE_BUSINESS_OPTIONS = z.object({
|
|
136
|
+
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
|
|
137
|
+
cta: z.object({
|
|
138
|
+
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."),
|
|
139
|
+
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."),
|
|
140
|
+
}).optional().describe("Optional call-to-action button."),
|
|
141
|
+
event: z.object({
|
|
142
|
+
title: z.string().max(58).describe("Event title (max 58 chars)."),
|
|
143
|
+
schedule: z.object({
|
|
144
|
+
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
145
|
+
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
146
|
+
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
147
|
+
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
148
|
+
}).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
|
|
149
|
+
}).optional().describe("Required when topic_type is EVENT."),
|
|
150
|
+
offer: z.object({
|
|
151
|
+
couponCode: z.string().max(58).optional(),
|
|
152
|
+
redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
|
|
153
|
+
termsConditions: z.string().max(4000).optional(),
|
|
154
|
+
}).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
|
|
155
|
+
}).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.");
|
|
62
156
|
// Render full content for get_post — preserves per-platform overrides so Claude
|
|
63
157
|
// can see custom captions (e.g. a shorter X version) instead of only `default`.
|
|
64
158
|
function renderContent(content) {
|
|
@@ -128,7 +222,7 @@ export function registerPostTools(server, getClient) {
|
|
|
128
222
|
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
129
223
|
};
|
|
130
224
|
}
|
|
131
|
-
const posts = Array.isArray(result.data) ? result.data :
|
|
225
|
+
const posts = Array.isArray(result.data) ? result.data : [];
|
|
132
226
|
if (!posts.length) {
|
|
133
227
|
return {
|
|
134
228
|
content: [{ type: "text", text: `No posts found${params.status ? ` with status "${params.status}"` : ""}.` }],
|
|
@@ -140,12 +234,13 @@ export function registerPostTools(server, getClient) {
|
|
|
140
234
|
md += `|---|---------|----------|--------|------|----|\n`;
|
|
141
235
|
for (let i = 0; i < posts.length; i++) {
|
|
142
236
|
const p = posts[i];
|
|
143
|
-
const
|
|
144
|
-
const rawContent =
|
|
145
|
-
? (
|
|
146
|
-
: (p.content
|
|
147
|
-
const hasOverrides =
|
|
148
|
-
const
|
|
237
|
+
const contentObj = typeof p.content === "object" && p.content !== null ? p.content : null;
|
|
238
|
+
const rawContent = contentObj
|
|
239
|
+
? (contentObj.default || Object.values(contentObj).find((v) => typeof v === "string" && v) || "")
|
|
240
|
+
: (typeof p.content === "string" ? p.content : "");
|
|
241
|
+
const hasOverrides = !!contentObj && Object.keys(contentObj).filter((k) => k !== "default" && typeof contentObj[k] === "string" && contentObj[k]).length > 0;
|
|
242
|
+
const xParts = p.x?.thread_parts;
|
|
243
|
+
const threadParts = Array.isArray(xParts) ? xParts : [];
|
|
149
244
|
const isThread = threadParts.length >= 2;
|
|
150
245
|
let content;
|
|
151
246
|
if (isThread && !rawContent) {
|
|
@@ -192,7 +287,7 @@ export function registerPostTools(server, getClient) {
|
|
|
192
287
|
md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
|
|
193
288
|
if (p.published_at)
|
|
194
289
|
md += `| **Published** | ${formatDateTime(p.published_at)} |\n`;
|
|
195
|
-
md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
|
|
290
|
+
md += `| **Created** | ${formatDateTime(p.created_at ?? null)} |\n`;
|
|
196
291
|
if (appUrl)
|
|
197
292
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
198
293
|
// Retry linkage: a "published" post with `retries` set and empty
|
|
@@ -236,7 +331,7 @@ export function registerPostTools(server, getClient) {
|
|
|
236
331
|
md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
|
|
237
332
|
}
|
|
238
333
|
items.forEach((m, i) => {
|
|
239
|
-
const alt = typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
|
|
334
|
+
const alt = typeof m !== "string" && typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
|
|
240
335
|
md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}${alt}\n`;
|
|
241
336
|
});
|
|
242
337
|
md += `\n`;
|
|
@@ -244,9 +339,8 @@ export function registerPostTools(server, getClient) {
|
|
|
244
339
|
md = md.trimEnd();
|
|
245
340
|
}
|
|
246
341
|
// X thread: when present, the canonical tweet text lives here, not in `content`.
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
: [];
|
|
342
|
+
const rawXParts = p.x?.thread_parts;
|
|
343
|
+
const threadParts = Array.isArray(rawXParts) ? rawXParts : [];
|
|
250
344
|
if (threadParts.length >= 2) {
|
|
251
345
|
md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
|
|
252
346
|
for (let i = 0; i < threadParts.length; i++) {
|
|
@@ -263,9 +357,8 @@ export function registerPostTools(server, getClient) {
|
|
|
263
357
|
}
|
|
264
358
|
}
|
|
265
359
|
// Bluesky thread: when present, the canonical post text lives here, not in `content`.
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
: [];
|
|
360
|
+
const rawBskyParts = p.bluesky?.thread_parts;
|
|
361
|
+
const bskyThreadParts = Array.isArray(rawBskyParts) ? rawBskyParts : [];
|
|
269
362
|
if (bskyThreadParts.length >= 2) {
|
|
270
363
|
md += `\n\n### Bluesky Thread (${bskyThreadParts.length} parts)\n\n`;
|
|
271
364
|
for (let i = 0; i < bskyThreadParts.length; i++) {
|
|
@@ -282,9 +375,8 @@ export function registerPostTools(server, getClient) {
|
|
|
282
375
|
}
|
|
283
376
|
}
|
|
284
377
|
// Mastodon thread: when present, the canonical status text lives here, not in `content`.
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
: [];
|
|
378
|
+
const rawMastoParts = p.mastodon?.thread_parts;
|
|
379
|
+
const mastoThreadParts = Array.isArray(rawMastoParts) ? rawMastoParts : [];
|
|
288
380
|
if (mastoThreadParts.length >= 2) {
|
|
289
381
|
md += `\n\n### Mastodon Thread (${mastoThreadParts.length} parts)\n\n`;
|
|
290
382
|
for (let i = 0; i < mastoThreadParts.length; i++) {
|
|
@@ -413,6 +505,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
413
505
|
- YouTube: Title, privacy status, tags?
|
|
414
506
|
- TikTok: Privacy level?
|
|
415
507
|
- **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
|
|
508
|
+
- **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
|
|
416
509
|
|
|
417
510
|
Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
|
|
418
511
|
|
|
@@ -427,7 +520,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
427
520
|
media_urls: z.union([
|
|
428
521
|
z.array(mediaUrlEntry),
|
|
429
522
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
430
|
-
]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB. 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. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
|
|
523
|
+
]).superRefine(pinterestImageCap).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Each entry is a plain URL string or { url, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB. 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. For files over 100 MB (up to 1 GB): upload_media with method 'url' first, then pass the returned media id in `media`."),
|
|
431
524
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
|
|
432
525
|
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)."),
|
|
433
526
|
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."),
|
|
@@ -435,22 +528,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
435
528
|
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)."),
|
|
436
529
|
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."),
|
|
437
530
|
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."),
|
|
438
|
-
user_tags: z.array(
|
|
439
|
-
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
440
|
-
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
441
|
-
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
442
|
-
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
443
|
-
})).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."),
|
|
531
|
+
user_tags: z.array(USER_TAG_ENTRY).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."),
|
|
444
532
|
hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 30-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
|
|
445
533
|
hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
|
|
446
534
|
hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
|
|
447
|
-
pinterest:
|
|
448
|
-
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
449
|
-
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."),
|
|
450
|
-
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
451
|
-
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
452
|
-
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
453
|
-
}).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."),
|
|
535
|
+
pinterest: PINTEREST_OPTIONS,
|
|
454
536
|
youtube: z.object({
|
|
455
537
|
title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
|
|
456
538
|
tags: z.array(z.string()).optional(),
|
|
@@ -472,66 +554,25 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
472
554
|
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
|
|
473
555
|
is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
|
|
474
556
|
trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
|
|
557
|
+
is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
|
|
475
558
|
}).optional().describe("Instagram options"),
|
|
476
559
|
facebook: FACEBOOK_OPTIONS,
|
|
477
560
|
linkedin: LINKEDIN_OPTIONS,
|
|
478
561
|
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
479
|
-
tiktok:
|
|
480
|
-
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
481
|
-
disable_comment: z.boolean().optional(),
|
|
482
|
-
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
483
|
-
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
484
|
-
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
485
|
-
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
486
|
-
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
487
|
-
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
488
|
-
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
489
|
-
}).optional().describe("TikTok options"),
|
|
562
|
+
tiktok: TIKTOK_OPTIONS,
|
|
490
563
|
x: z.object({
|
|
491
564
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
492
565
|
paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
|
|
493
566
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
494
|
-
thread_parts: z.array(
|
|
495
|
-
text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
|
|
496
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
|
|
497
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
|
|
498
|
-
})).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."),
|
|
567
|
+
thread_parts: z.array(X_THREAD_PART).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."),
|
|
499
568
|
}).optional().describe("X (Twitter) options"),
|
|
500
569
|
bluesky: z.object({
|
|
501
|
-
thread_parts: z.array(
|
|
502
|
-
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
503
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
504
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
505
|
-
})).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
|
|
570
|
+
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
|
|
506
571
|
}).optional().describe("Bluesky options"),
|
|
507
572
|
mastodon: z.object({
|
|
508
|
-
thread_parts: z.array(
|
|
509
|
-
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
510
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
511
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
512
|
-
})).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
|
|
573
|
+
thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
|
|
513
574
|
}).optional().describe("Mastodon options"),
|
|
514
|
-
google_business:
|
|
515
|
-
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
|
|
516
|
-
cta: z.object({
|
|
517
|
-
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."),
|
|
518
|
-
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."),
|
|
519
|
-
}).optional().describe("Optional call-to-action button."),
|
|
520
|
-
event: z.object({
|
|
521
|
-
title: z.string().max(58).describe("Event title (max 58 chars)."),
|
|
522
|
-
schedule: z.object({
|
|
523
|
-
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
524
|
-
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
525
|
-
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
526
|
-
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
527
|
-
}).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
|
|
528
|
-
}).optional().describe("Required when topic_type is EVENT."),
|
|
529
|
-
offer: z.object({
|
|
530
|
-
couponCode: z.string().max(58).optional(),
|
|
531
|
-
redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
|
|
532
|
-
termsConditions: z.string().max(4000).optional(),
|
|
533
|
-
}).optional().describe("Required when topic_type is OFFER. Must include at least one of couponCode or redeemOnlineUrl."),
|
|
534
|
-
}).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."),
|
|
575
|
+
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
535
576
|
}, async (params) => {
|
|
536
577
|
const result = await getClient().createPost({ ...params, source: "mcp" });
|
|
537
578
|
if (result.error) {
|
|
@@ -540,6 +581,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
540
581
|
};
|
|
541
582
|
}
|
|
542
583
|
const p = result.data;
|
|
584
|
+
if (!p) {
|
|
585
|
+
// Impossible path (no error + no data) — never surface it as a tool
|
|
586
|
+
// error: agents retry on errors and that produces orphan duplicates.
|
|
587
|
+
return {
|
|
588
|
+
content: [{ type: "text", text: "Draft created, but the API returned no details. Verify with list_posts." }],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
543
591
|
// The post is already created by the time this runs. If response
|
|
544
592
|
// formatting throws, we must NOT surface it as a tool error — agents
|
|
545
593
|
// retry on errors and that produces orphan duplicate posts.
|
|
@@ -559,6 +607,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
559
607
|
if (appUrl)
|
|
560
608
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
561
609
|
md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
|
|
610
|
+
if (Array.isArray(result.warnings) && result.warnings.length > 0) {
|
|
611
|
+
for (const w of result.warnings) {
|
|
612
|
+
md += `⚠️ ${w.message}\n`;
|
|
613
|
+
}
|
|
614
|
+
md += `\n`;
|
|
615
|
+
}
|
|
562
616
|
md += `_Share the link above with the user so they can review or edit the draft. Publish later with \`publish_post\` once they confirm._`;
|
|
563
617
|
return { content: [{ type: "text", text: md }] };
|
|
564
618
|
}
|
|
@@ -597,6 +651,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
597
651
|
- **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.
|
|
598
652
|
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.
|
|
599
653
|
5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
|
|
654
|
+
6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
|
|
600
655
|
|
|
601
656
|
Do NOT call without required media — it will fail.`, {
|
|
602
657
|
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."),
|
|
@@ -608,33 +663,27 @@ Do NOT call without required media — it will fail.`, {
|
|
|
608
663
|
media_urls: z.union([
|
|
609
664
|
z.array(mediaUrlEntry),
|
|
610
665
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
611
|
-
]).optional().describe("External image/video URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
666
|
+
]).superRefine(pinterestImageCap).optional().describe("External image/video URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
612
667
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
|
|
613
668
|
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."),
|
|
614
669
|
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."),
|
|
615
|
-
user_tags: z.array(
|
|
616
|
-
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
617
|
-
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
618
|
-
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
619
|
-
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
620
|
-
})).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."),
|
|
670
|
+
user_tags: z.array(USER_TAG_ENTRY).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."),
|
|
621
671
|
hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. Tags are merged in once at create time; tags already in a caption are skipped."),
|
|
622
672
|
hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("caption_append (default) appends tags to the captions; first_comment posts them as the auto first comment on comment-capable platforms (others fall back to caption)."),
|
|
623
673
|
hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
|
|
624
|
-
pinterest:
|
|
625
|
-
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
626
|
-
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."),
|
|
627
|
-
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
628
|
-
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
629
|
-
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
630
|
-
}).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."),
|
|
674
|
+
pinterest: PINTEREST_OPTIONS,
|
|
631
675
|
youtube: z.object({
|
|
632
676
|
title: z.string().optional().describe("Short title shown on YouTube."),
|
|
633
677
|
tags: z.array(z.string()).optional(),
|
|
634
678
|
privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
|
|
679
|
+
category_id: z.string().optional(),
|
|
680
|
+
made_for_kids: z.boolean().optional(),
|
|
681
|
+
notify_subscribers: z.boolean().optional(),
|
|
682
|
+
contains_synthetic_media: z.boolean().optional(),
|
|
635
683
|
first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
|
|
636
684
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
637
685
|
instagram: z.object({
|
|
686
|
+
share_to_feed: z.boolean().optional(),
|
|
638
687
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
|
|
639
688
|
thumb_offset: z.number().optional().describe("Reel cover frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
|
|
640
689
|
cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
|
|
@@ -644,66 +693,25 @@ Do NOT call without required media — it will fail.`, {
|
|
|
644
693
|
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
|
|
645
694
|
is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account (the user sees a 'Trial' toggle when creating a reel in the Instagram app). Ineligible accounts fail at publish time with a clear per-platform error. Reels only."),
|
|
646
695
|
trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
|
|
696
|
+
is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
|
|
647
697
|
}).optional().describe("Instagram options"),
|
|
648
698
|
facebook: FACEBOOK_OPTIONS,
|
|
649
699
|
linkedin: LINKEDIN_OPTIONS,
|
|
650
700
|
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
651
|
-
tiktok:
|
|
652
|
-
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
653
|
-
disable_comment: z.boolean().optional(),
|
|
654
|
-
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
655
|
-
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
656
|
-
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
657
|
-
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
658
|
-
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
659
|
-
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
660
|
-
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
661
|
-
}).optional().describe("TikTok options"),
|
|
701
|
+
tiktok: TIKTOK_OPTIONS,
|
|
662
702
|
x: z.object({
|
|
663
703
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
664
704
|
paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
|
|
665
705
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
666
|
-
thread_parts: z.array(
|
|
667
|
-
text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
|
|
668
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
|
|
669
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
|
|
670
|
-
})).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."),
|
|
706
|
+
thread_parts: z.array(X_THREAD_PART).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."),
|
|
671
707
|
}).optional().describe("X (Twitter) options"),
|
|
672
708
|
bluesky: z.object({
|
|
673
|
-
thread_parts: z.array(
|
|
674
|
-
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
675
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
676
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
677
|
-
})).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
|
|
709
|
+
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
|
|
678
710
|
}).optional().describe("Bluesky options"),
|
|
679
711
|
mastodon: z.object({
|
|
680
|
-
thread_parts: z.array(
|
|
681
|
-
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
682
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
683
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
684
|
-
})).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
|
|
712
|
+
thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
|
|
685
713
|
}).optional().describe("Mastodon options"),
|
|
686
|
-
google_business:
|
|
687
|
-
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
688
|
-
cta: z.object({
|
|
689
|
-
actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
|
|
690
|
-
url: z.string().optional(),
|
|
691
|
-
}).optional(),
|
|
692
|
-
event: z.object({
|
|
693
|
-
title: z.string().max(58),
|
|
694
|
-
schedule: z.object({
|
|
695
|
-
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
696
|
-
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
697
|
-
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
698
|
-
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
699
|
-
}).optional(),
|
|
700
|
-
}).optional(),
|
|
701
|
-
offer: z.object({
|
|
702
|
-
couponCode: z.string().max(58).optional(),
|
|
703
|
-
redeemOnlineUrl: z.string().optional(),
|
|
704
|
-
termsConditions: z.string().max(4000).optional(),
|
|
705
|
-
}).optional(),
|
|
706
|
-
}).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."),
|
|
714
|
+
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
707
715
|
}, async (params) => {
|
|
708
716
|
const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
|
|
709
717
|
if (result.error) {
|
|
@@ -712,6 +720,13 @@ Do NOT call without required media — it will fail.`, {
|
|
|
712
720
|
};
|
|
713
721
|
}
|
|
714
722
|
const p = result.data;
|
|
723
|
+
if (!p) {
|
|
724
|
+
// Impossible path (no error + no data) — the post is already queued;
|
|
725
|
+
// never let it look like an API error (agents would retry = repost).
|
|
726
|
+
return {
|
|
727
|
+
content: [{ type: "text", text: "Post created and queued for publishing, but the API returned no details. Verify with list_posts." }],
|
|
728
|
+
};
|
|
729
|
+
}
|
|
715
730
|
const appUrl = postAppUrl(p);
|
|
716
731
|
try {
|
|
717
732
|
const channels = selectedChannels(p);
|
|
@@ -725,6 +740,12 @@ Do NOT call without required media — it will fail.`, {
|
|
|
725
740
|
if (appUrl)
|
|
726
741
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
727
742
|
md += `\n**Content:** ${truncate(p.content, 100)}`;
|
|
743
|
+
if (Array.isArray(result.warnings) && result.warnings.length > 0) {
|
|
744
|
+
md += `\n`;
|
|
745
|
+
for (const w of result.warnings) {
|
|
746
|
+
md += `\n⚠️ ${w.message}`;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
728
749
|
return { content: [{ type: "text", text: md }] };
|
|
729
750
|
}
|
|
730
751
|
catch {
|
|
@@ -737,7 +758,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
737
758
|
});
|
|
738
759
|
server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
|
|
739
760
|
|
|
740
|
-
**Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
|
|
761
|
+
**Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
|
|
741
762
|
|
|
742
763
|
**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.`, {
|
|
743
764
|
id: z.string().describe("The post ID to update"),
|
|
@@ -751,15 +772,10 @@ Do NOT call without required media — it will fail.`, {
|
|
|
751
772
|
media_urls: z.union([
|
|
752
773
|
z.array(mediaUrlEntry),
|
|
753
774
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
754
|
-
]).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total, each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
775
|
+
]).superRefine(pinterestImageCap).optional().describe("External URLs — flat array or per-platform object. Each entry is a plain URL string or { url, alt } to attach alt text — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images). Max 10 total (Pinterest: max 5 images per carousel pin), each file ≤ 100 MB (larger, up to 1 GB: upload_media with method 'url' → pass the media id in `media`). 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
755
776
|
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."),
|
|
756
777
|
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."),
|
|
757
|
-
user_tags: z.array(
|
|
758
|
-
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
759
|
-
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
760
|
-
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
761
|
-
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
762
|
-
})).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."),
|
|
778
|
+
user_tags: z.array(USER_TAG_ENTRY).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."),
|
|
763
779
|
youtube: z.object({
|
|
764
780
|
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)."),
|
|
765
781
|
tags: z.array(z.string()).optional(),
|
|
@@ -770,13 +786,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
770
786
|
contains_synthetic_media: z.boolean().optional(),
|
|
771
787
|
first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled. Pass an empty string to clear a previously set first comment."),
|
|
772
788
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
773
|
-
pinterest:
|
|
774
|
-
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
775
|
-
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."),
|
|
776
|
-
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
777
|
-
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
778
|
-
alt_text: z.string().optional().describe("Accessibility alt text for the pin image (max 500 characters)"),
|
|
779
|
-
}).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."),
|
|
789
|
+
pinterest: PINTEREST_OPTIONS,
|
|
780
790
|
instagram: z.object({
|
|
781
791
|
share_to_feed: z.boolean().optional(),
|
|
782
792
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional().describe("How the Reel cover is chosen: 'from-video' picks a frame at thumb_offset; 'from-library' uses the image at cover_url. get_post reads the chosen cover back."),
|
|
@@ -788,66 +798,25 @@ Do NOT call without required media — it will fail.`, {
|
|
|
788
798
|
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Pass an empty string to clear a previously set first comment."),
|
|
789
799
|
is_trial_reel: z.boolean().optional().describe("Publish the reel as an Instagram Trial Reel — shown to non-followers first to test performance before (optionally) graduating to everyone. ONLY set this when the user explicitly asks for a Trial Reel; never enable it by default. NOT available on every account: Instagram requires roughly 1,000+ followers and enables the feature per account. Ineligible accounts fail at publish time with a clear per-platform error. Reels only. Pass false to turn a Trial Reel back into a regular reel."),
|
|
790
800
|
trial_graduation_strategy: z.enum(["MANUAL", "SS_PERFORMANCE"]).optional().describe("How a Trial Reel graduates to all followers. MANUAL (default): the user decides in the Instagram app. SS_PERFORMANCE: Instagram shares it with followers automatically if it performs well. Only used with is_trial_reel."),
|
|
801
|
+
is_ai_generated: z.boolean().optional().describe("Self-disclosure that this post's media is AI-generated — adds Instagram's 'AI info' label. Applies to single images, videos, Posts, carousels (whole post, not individual slides), and Reels. Cannot be changed after publish. Not available for Stories."),
|
|
791
802
|
}).optional().describe("Instagram options. MERGED into the post's stored options: omitted keys keep their current value (updating one option cannot clear a Trial Reel flag or reel music configured elsewhere)."),
|
|
792
803
|
facebook: FACEBOOK_OPTIONS,
|
|
793
804
|
linkedin: LINKEDIN_OPTIONS,
|
|
794
805
|
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
795
|
-
tiktok:
|
|
796
|
-
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
797
|
-
disable_comment: z.boolean().optional(),
|
|
798
|
-
disable_duet: z.boolean().optional().describe("Reels only. Disable Duet on the video."),
|
|
799
|
-
disable_stitch: z.boolean().optional().describe("Reels only. Disable Stitch on the video."),
|
|
800
|
-
video_cover_timestamp_ms: z.number().optional().describe("Reels only. Timestamp (ms) of the video frame to use as the cover."),
|
|
801
|
-
is_aigc: z.boolean().optional().describe("Mark as AI-generated content."),
|
|
802
|
-
brand_content_toggle: z.boolean().optional().describe("Paid partnership disclosure (promotes a third-party brand)."),
|
|
803
|
-
brand_organic_toggle: z.boolean().optional().describe("Your own brand disclosure (promotes your own business)."),
|
|
804
|
-
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
805
|
-
}).optional().describe("TikTok options"),
|
|
806
|
+
tiktok: TIKTOK_OPTIONS,
|
|
806
807
|
x: z.object({
|
|
807
808
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
808
809
|
paid_partnership: z.boolean().optional(),
|
|
809
810
|
made_with_ai: z.boolean().optional(),
|
|
810
|
-
thread_parts: z.array(
|
|
811
|
-
text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
|
|
812
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
|
|
813
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
|
|
814
|
-
})).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."),
|
|
811
|
+
thread_parts: z.array(X_THREAD_PART).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."),
|
|
815
812
|
}).optional().describe("X (Twitter) options"),
|
|
816
813
|
bluesky: z.object({
|
|
817
|
-
thread_parts: z.array(
|
|
818
|
-
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
819
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
820
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
821
|
-
})).min(2).max(25).nullable().optional().describe("Replace the Bluesky 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; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
|
|
814
|
+
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Bluesky 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; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
|
|
822
815
|
}).optional().describe("Bluesky options"),
|
|
823
816
|
mastodon: z.object({
|
|
824
|
-
thread_parts: z.array(
|
|
825
|
-
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
826
|
-
media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4). Each entry is a plain ID string or { id, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
827
|
-
media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-status media as external URLs (max 4). Each entry is a plain URL string or { url, alt } to attach alt text (set as the media description — the Mastodon community strongly values alt text on images)."),
|
|
828
|
-
})).min(2).max(25).nullable().optional().describe("Replace the Mastodon 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-status mode."),
|
|
817
|
+
thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Mastodon 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-status mode."),
|
|
829
818
|
}).optional().describe("Mastodon options"),
|
|
830
|
-
google_business:
|
|
831
|
-
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
832
|
-
cta: z.object({
|
|
833
|
-
actionType: z.enum(["LEARN_MORE", "BOOK", "ORDER", "SHOP", "SIGN_UP", "CALL"]),
|
|
834
|
-
url: z.string().optional(),
|
|
835
|
-
}).optional(),
|
|
836
|
-
event: z.object({
|
|
837
|
-
title: z.string().max(58),
|
|
838
|
-
schedule: z.object({
|
|
839
|
-
startDate: z.object({ year: z.number(), month: z.number(), day: z.number() }),
|
|
840
|
-
startTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
841
|
-
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
842
|
-
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
843
|
-
}).optional(),
|
|
844
|
-
}).optional(),
|
|
845
|
-
offer: z.object({
|
|
846
|
-
couponCode: z.string().max(58).optional(),
|
|
847
|
-
redeemOnlineUrl: z.string().optional(),
|
|
848
|
-
termsConditions: z.string().max(4000).optional(),
|
|
849
|
-
}).optional(),
|
|
850
|
-
}).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."),
|
|
819
|
+
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
851
820
|
}, async ({ id, ...data }) => {
|
|
852
821
|
const result = await getClient().updatePost(id, data);
|
|
853
822
|
if (result.error) {
|
|
@@ -856,6 +825,11 @@ Do NOT call without required media — it will fail.`, {
|
|
|
856
825
|
};
|
|
857
826
|
}
|
|
858
827
|
const p = result.data;
|
|
828
|
+
if (!p) {
|
|
829
|
+
return {
|
|
830
|
+
content: [{ type: "text", text: "Post updated, but the API returned no details. Verify with get_post." }],
|
|
831
|
+
};
|
|
832
|
+
}
|
|
859
833
|
const schedAt = p.schedule_at ?? p.scheduled_at;
|
|
860
834
|
const appUrl = postAppUrl(p);
|
|
861
835
|
let md = `## Post Updated\n\n`;
|
|
@@ -891,6 +865,11 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
891
865
|
};
|
|
892
866
|
}
|
|
893
867
|
const p = result.data;
|
|
868
|
+
if (!p) {
|
|
869
|
+
return {
|
|
870
|
+
content: [{ type: "text", text: "Post queued for publishing. Check its status with get_post." }],
|
|
871
|
+
};
|
|
872
|
+
}
|
|
894
873
|
let md = `## Post Queued for Publishing\n\n`;
|
|
895
874
|
md += `| Field | Value |\n`;
|
|
896
875
|
md += `|-------|-------|\n`;
|
|
@@ -912,6 +891,11 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
|
|
|
912
891
|
};
|
|
913
892
|
}
|
|
914
893
|
const p = result.data;
|
|
894
|
+
if (!p) {
|
|
895
|
+
return {
|
|
896
|
+
content: [{ type: "text", text: "Retry queued. Check the outcome with get_post in a minute or two." }],
|
|
897
|
+
};
|
|
898
|
+
}
|
|
915
899
|
let md = `## Retry Queued\n\n`;
|
|
916
900
|
md += `| Field | Value |\n`;
|
|
917
901
|
md += `|-------|-------|\n`;
|
|
@@ -940,7 +924,10 @@ Notes:
|
|
|
940
924
|
const result = await getClient().searchLocations(query);
|
|
941
925
|
const places = result?.data || [];
|
|
942
926
|
if (!places.length) {
|
|
943
|
-
|
|
927
|
+
// The degraded path returns `error` as a plain string; the object form
|
|
928
|
+
// only appears on transport failures — render its message.
|
|
929
|
+
const err = result?.error;
|
|
930
|
+
const why = (typeof err === "string" ? err : err?.message) ||
|
|
944
931
|
`No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
|
|
945
932
|
return { content: [{ type: "text", text: why }] };
|
|
946
933
|
}
|
|
@@ -968,7 +955,10 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
968
955
|
const result = await getClient().searchInstagramAudio(query, type);
|
|
969
956
|
const tracks = result?.data || [];
|
|
970
957
|
if (!tracks.length) {
|
|
971
|
-
|
|
958
|
+
// Same nonstandard envelope as search_locations — string `error` on
|
|
959
|
+
// the degraded path, { code, message } only on transport failures.
|
|
960
|
+
const err = result?.error;
|
|
961
|
+
const why = (typeof err === "string" ? err : err?.message) ||
|
|
972
962
|
(query
|
|
973
963
|
? `No tracks found for "${query}". Try another title or artist — only music licensed for third-party publishing appears here.`
|
|
974
964
|
: "No trending audio available right now.");
|
|
@@ -987,11 +977,11 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
987
977
|
});
|
|
988
978
|
return { content: [{ type: "text", text: md }] };
|
|
989
979
|
});
|
|
990
|
-
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live, so expect a few seconds of latency. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
|
|
980
|
+
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` plus every raw metric the platform reported (Instagram: reach/views/saves/shares from per-post insights). Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); Threads, Pinterest, and Google Business return captions only. Records also carry `duration_seconds` — the video length in whole seconds — where the platform's listing API reports it (currently TikTok and YouTube); null for images and platforms that don't expose it. LinkedIn personal profiles can't be listed live (LinkedIn grants apps no such permission), so their results are posts published through OmniSocials with their latest collected stats. Fetched live for most platforms, so expect a few seconds of latency; X results may come from a snapshot up to 24h old (X bills per returned post) — the snapshot refreshes right after the user publishes to X through OmniSocials. Output is a human-readable summary table PLUS a 'Structured data' JSON block carrying, for every post, the platform's own post id (the stable dedupe key), a permalink, the FULL untruncated caption, and exact-integer metrics — use that block when ingesting or storing native posts rather than the rounded/truncated table. Requires the analytics:read scope.", {
|
|
991
981
|
limit: z
|
|
992
982
|
.string()
|
|
993
983
|
.optional()
|
|
994
|
-
.describe("Max posts per connected platform (1-50, default 25)."),
|
|
984
|
+
.describe("Max posts per connected platform (1-50, default 25; X defaults to 10 unless set explicitly — its API bills per returned post)."),
|
|
995
985
|
platforms: z
|
|
996
986
|
.string()
|
|
997
987
|
.optional()
|