@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 +1 -0
- package/build/client.d.ts +16 -0
- package/build/client.js +7 -0
- package/build/tools/accounts.js +8 -6
- package/build/tools/analytics.js +3 -3
- package/build/tools/media.js +3 -3
- package/build/tools/posts.js +126 -16
- package/build/tools/webhooks.js +6 -6
- package/build/types.d.ts +8 -0
- package/package.json +1 -1
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);
|
package/build/tools/accounts.js
CHANGED
|
@@ -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
|
|
27
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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`;
|
package/build/tools/analytics.js
CHANGED
|
@@ -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 || [];
|
package/build/tools/media.js
CHANGED
|
@@ -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
|
}
|
package/build/tools/posts.js
CHANGED
|
@@ -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
|
}
|
package/build/tools/webhooks.js
CHANGED
|
@@ -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