@omnisocials/mcp-server 1.19.0 → 1.20.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 +5 -0
- package/build/index.js +1 -1
- package/build/metrics.d.ts +24 -0
- package/build/metrics.js +111 -0
- package/build/tools/analytics.js +34 -19
- package/build/tools/posts.js +31 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -218,6 +218,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
218
218
|
|
|
219
219
|
## Changelog
|
|
220
220
|
|
|
221
|
+
### 1.20.0 (2026-08-04)
|
|
222
|
+
|
|
223
|
+
- **Engagement rate now matches the dashboard:** `get_post_analytics` / `get_posts_analytics` previously summed likes + comments + shares only, dropping LinkedIn link clicks and X quotes/bookmarks from engagement. Both tools now use the same normalization as the dashboard and `get_analytics_overview`; rates are suppressed below 10 impressions and capped at 100%.
|
|
224
|
+
- **Account metric semantics clarified:** LinkedIn account-level `impressions` on `get_account_analytics` are lifetime totals across all of the account's content (not a windowed count) — the tool now says so and renders each platform's scope `note`.
|
|
225
|
+
|
|
221
226
|
### 1.19.0 (2026-08-02)
|
|
222
227
|
|
|
223
228
|
- **Alt text now reaches Instagram and LinkedIn:** per-media alt text (`{ url, alt }` / `{ id, alt }` entries) is also delivered to Instagram (image posts and carousel image slides — not Reels/Stories) and LinkedIn (images only — not video or documents), alongside Mastodon, Bluesky, X, and Pinterest.
|
package/build/index.js
CHANGED
|
@@ -29,7 +29,7 @@ const sessionState = { activeIndex: 0 };
|
|
|
29
29
|
const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
|
|
30
30
|
const server = new McpServer({
|
|
31
31
|
name: "OmniSocials",
|
|
32
|
-
version: "1.
|
|
32
|
+
version: "1.20.0",
|
|
33
33
|
});
|
|
34
34
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
35
35
|
registerPostTools(server, getActiveClient);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engagement / impressions normalizers — VENDORED COPY of the repo's
|
|
3
|
+
* `shared/analytics-metrics/index.js` (the single source of truth used by the
|
|
4
|
+
* backend, public API, remote MCP, and dashboard). This package publishes to
|
|
5
|
+
* npm standalone, so it can't require() across the monorepo — keep this file
|
|
6
|
+
* in sync with shared/analytics-metrics when the rules change there.
|
|
7
|
+
*
|
|
8
|
+
* WHY: the MCP tools used to compute engagement inline as
|
|
9
|
+
* likes+comments+shares, which silently dropped LinkedIn clicks and X
|
|
10
|
+
* quotes/bookmarks — so the same post showed a different engagement rate in
|
|
11
|
+
* MCP output than on the dashboard or /analytics/overview.
|
|
12
|
+
*/
|
|
13
|
+
export declare const MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE = 10;
|
|
14
|
+
type Metrics = Record<string, unknown> | null | undefined;
|
|
15
|
+
/** Total engagement (interaction count) for a post's metrics on a platform. */
|
|
16
|
+
export declare function getEngagement(platform: string, metrics: Metrics): number;
|
|
17
|
+
/** Best-available impressions ("how many times seen") for a post's metrics. */
|
|
18
|
+
export declare function getImpressions(platform: string, metrics: Metrics): number;
|
|
19
|
+
/**
|
|
20
|
+
* Engagement rate as a percentage (0-100), suppressed below the impression
|
|
21
|
+
* floor and capped at 100%.
|
|
22
|
+
*/
|
|
23
|
+
export declare function getEngagementRate(engagement: number, impressions: number): number;
|
|
24
|
+
export {};
|
package/build/metrics.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engagement / impressions normalizers — VENDORED COPY of the repo's
|
|
3
|
+
* `shared/analytics-metrics/index.js` (the single source of truth used by the
|
|
4
|
+
* backend, public API, remote MCP, and dashboard). This package publishes to
|
|
5
|
+
* npm standalone, so it can't require() across the monorepo — keep this file
|
|
6
|
+
* in sync with shared/analytics-metrics when the rules change there.
|
|
7
|
+
*
|
|
8
|
+
* WHY: the MCP tools used to compute engagement inline as
|
|
9
|
+
* likes+comments+shares, which silently dropped LinkedIn clicks and X
|
|
10
|
+
* quotes/bookmarks — so the same post showed a different engagement rate in
|
|
11
|
+
* MCP output than on the dashboard or /analytics/overview.
|
|
12
|
+
*/
|
|
13
|
+
// Minimum impressions before an engagement-rate percentage is meaningful.
|
|
14
|
+
// Below this we suppress the rate rather than render a noisy ratio.
|
|
15
|
+
export const MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE = 10;
|
|
16
|
+
// Coerce anything (number, numeric string, null, undefined) to a finite number.
|
|
17
|
+
const num = (v) => {
|
|
18
|
+
if (typeof v === "number")
|
|
19
|
+
return Number.isFinite(v) ? v : 0;
|
|
20
|
+
const n = Number(v);
|
|
21
|
+
return Number.isFinite(n) ? n : 0;
|
|
22
|
+
};
|
|
23
|
+
/** Total engagement (interaction count) for a post's metrics on a platform. */
|
|
24
|
+
export function getEngagement(platform, metrics) {
|
|
25
|
+
const m = metrics || {};
|
|
26
|
+
// Trust a precomputed engagement count when a fetcher already supplied one
|
|
27
|
+
// (Instagram total_interactions, TikTok/Threads/Pinterest sums, Google
|
|
28
|
+
// Business account engagement). Only when it's a real positive number —
|
|
29
|
+
// a stored 0 means "not computed" for the platforms that don't set it.
|
|
30
|
+
const pre = num(m.engagement);
|
|
31
|
+
if (pre > 0)
|
|
32
|
+
return pre;
|
|
33
|
+
const sum = (...keys) => keys.reduce((t, k) => t + num(m[k]), 0);
|
|
34
|
+
switch (platform) {
|
|
35
|
+
case "facebook":
|
|
36
|
+
// total_reactions already aggregates like/love/wow/...; fall back to the
|
|
37
|
+
// bare like count only when the aggregate is absent (never add both).
|
|
38
|
+
return (num(m.total_reactions) || num(m.likes)) + num(m.comments) + num(m.shares);
|
|
39
|
+
case "linkedin":
|
|
40
|
+
case "linkedin_page":
|
|
41
|
+
// LinkedIn's native engagement includes link clicks.
|
|
42
|
+
return sum("likes", "comments", "shares", "clicks");
|
|
43
|
+
case "youtube":
|
|
44
|
+
return sum("likes", "comments", "shares");
|
|
45
|
+
case "x":
|
|
46
|
+
// X's engagement total includes bookmarks (saves).
|
|
47
|
+
return sum("likes", "comments", "reposts", "quotes", "bookmarks");
|
|
48
|
+
case "threads":
|
|
49
|
+
// Threads stores replies (not comments) as the comment-equivalent.
|
|
50
|
+
return num(m.likes) + (num(m.comments) || num(m.replies)) + num(m.reposts) + num(m.quotes);
|
|
51
|
+
case "bluesky":
|
|
52
|
+
return sum("likes", "comments", "reposts", "quotes");
|
|
53
|
+
case "mastodon":
|
|
54
|
+
return sum("likes", "comments", "reposts");
|
|
55
|
+
case "reddit":
|
|
56
|
+
// likes == upvote score; shares == crossposts.
|
|
57
|
+
return sum("likes", "comments", "shares");
|
|
58
|
+
case "instagram":
|
|
59
|
+
return sum("likes", "comments", "saves", "shares");
|
|
60
|
+
case "tiktok":
|
|
61
|
+
return sum("likes", "comments", "shares");
|
|
62
|
+
case "pinterest":
|
|
63
|
+
return sum("saves", "pin_clicks", "outbound_clicks");
|
|
64
|
+
case "google_business":
|
|
65
|
+
// engagement is precomputed at account level; nothing to sum per post.
|
|
66
|
+
return pre;
|
|
67
|
+
default:
|
|
68
|
+
// Generic safe sum of common interaction fields for any future platform.
|
|
69
|
+
return sum("likes", "comments", "replies", "shares", "reposts", "quotes", "saves");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Best-available impressions ("how many times seen") for a post's metrics. */
|
|
73
|
+
export function getImpressions(platform, metrics) {
|
|
74
|
+
const m = metrics || {};
|
|
75
|
+
switch (platform) {
|
|
76
|
+
case "instagram":
|
|
77
|
+
// Reels/video report `views` (play count) which is the true total-seen
|
|
78
|
+
// number and exceeds unique `reach`. Prefer it; fall back for old rows.
|
|
79
|
+
return num(m.views) || num(m.impressions) || num(m.reach);
|
|
80
|
+
case "facebook":
|
|
81
|
+
// Facebook exposes reach/impressions; video posts also report video_views.
|
|
82
|
+
return num(m.reach) || num(m.impressions) || num(m.video_views) || num(m.views);
|
|
83
|
+
case "linkedin":
|
|
84
|
+
case "linkedin_page":
|
|
85
|
+
case "pinterest":
|
|
86
|
+
case "google_business":
|
|
87
|
+
return num(m.impressions) || num(m.views) || num(m.reach);
|
|
88
|
+
case "youtube":
|
|
89
|
+
case "tiktok":
|
|
90
|
+
case "x":
|
|
91
|
+
case "threads":
|
|
92
|
+
case "bluesky":
|
|
93
|
+
case "mastodon":
|
|
94
|
+
case "reddit":
|
|
95
|
+
default:
|
|
96
|
+
return num(m.views) || num(m.impressions) || num(m.reach);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Engagement rate as a percentage (0-100), suppressed below the impression
|
|
101
|
+
* floor and capped at 100%.
|
|
102
|
+
*/
|
|
103
|
+
export function getEngagementRate(engagement, impressions) {
|
|
104
|
+
const imp = num(impressions);
|
|
105
|
+
if (imp < MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE)
|
|
106
|
+
return 0;
|
|
107
|
+
const rate = (num(engagement) / imp) * 100;
|
|
108
|
+
if (!Number.isFinite(rate) || rate < 0)
|
|
109
|
+
return 0;
|
|
110
|
+
return Math.min(rate, 100);
|
|
111
|
+
}
|
package/build/tools/analytics.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatNumber, capitalize, metricRows, sortMetricLabels } from "../client.js";
|
|
3
|
+
import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE, } from "../metrics.js";
|
|
3
4
|
export function registerAnalyticsTools(server, getClient) {
|
|
4
5
|
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post. Renders every metric the platform reported — impressions, reach, views, saves, likes, comments, shares, clicks, engagements, and more — per platform.", {
|
|
5
6
|
post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
|
|
@@ -36,12 +37,14 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
36
37
|
for (const [platform, entry] of platformEntries) {
|
|
37
38
|
const m = entry?.metrics || {};
|
|
38
39
|
const rows = metricRows(m);
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
//
|
|
44
|
-
|
|
40
|
+
// Engagement/impressions come from the shared normalizer (vendored in
|
|
41
|
+
// ../metrics.js) so the rate shown here matches the dashboard,
|
|
42
|
+
// /analytics/overview, and recent-platform. The previous inline
|
|
43
|
+
// likes+comments+shares sum silently dropped LinkedIn clicks and X
|
|
44
|
+
// quotes/bookmarks, so the same post showed a different engagement
|
|
45
|
+
// rate depending on the surface.
|
|
46
|
+
const engagements = getEngagement(platform, m);
|
|
47
|
+
const denom = getImpressions(platform, m);
|
|
45
48
|
totalEngagements += engagements;
|
|
46
49
|
totalDenom += denom;
|
|
47
50
|
for (const [label, n] of rows) {
|
|
@@ -60,7 +63,11 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
60
63
|
if (!totalsByLabel.has("Engagements"))
|
|
61
64
|
totalOrder.push("Engagements");
|
|
62
65
|
totalsByLabel.set("Engagements", totalEngagements);
|
|
63
|
-
|
|
66
|
+
// Suppressed below the shared impression floor (tiny denominators make
|
|
67
|
+
// noisy percentages), matching every other surface.
|
|
68
|
+
const renderRate = (engagements, denom) => denom >= MIN_IMPRESSIONS_FOR_ENGAGEMENT_RATE
|
|
69
|
+
? `| **Engagement Rate** | ${getEngagementRate(engagements, denom).toFixed(2)}% |\n`
|
|
70
|
+
: "";
|
|
64
71
|
let md = perPlatform.length === 1
|
|
65
72
|
? `## Post Analytics — ${capitalize(perPlatform[0].platform)}\n`
|
|
66
73
|
: `## Post Analytics\n\n`;
|
|
@@ -112,18 +119,17 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
112
119
|
let likes = 0;
|
|
113
120
|
let comments = 0;
|
|
114
121
|
let shares = 0;
|
|
115
|
-
for (const [, p] of platformEntries) {
|
|
122
|
+
for (const [name, p] of platformEntries) {
|
|
116
123
|
const m = p?.metrics || {};
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
likes +=
|
|
124
|
-
comments +=
|
|
125
|
-
shares +=
|
|
126
|
-
engagements += m.engagement != null ? Number(m.engagement) : l + c + s;
|
|
124
|
+
// Shared normalizer keeps these totals identical to get_post_analytics,
|
|
125
|
+
// the dashboard, and /analytics/overview (LinkedIn clicks and X
|
|
126
|
+
// quotes/bookmarks count toward engagement; the old inline sum dropped
|
|
127
|
+
// them). Likes/comments/shares stay as display columns only.
|
|
128
|
+
impressions += getImpressions(name, m);
|
|
129
|
+
engagements += getEngagement(name, m);
|
|
130
|
+
likes += Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
|
|
131
|
+
comments += Number(m.comments ?? m.replies ?? 0);
|
|
132
|
+
shares += Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
|
|
127
133
|
}
|
|
128
134
|
return {
|
|
129
135
|
post_id: entry.post_id,
|
|
@@ -195,7 +201,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
195
201
|
content: [{ type: "text", text: md }],
|
|
196
202
|
};
|
|
197
203
|
});
|
|
198
|
-
server.tool("get_account_analytics", "Get account-level analytics (followers, subscribers, etc.) for connected social accounts.", {
|
|
204
|
+
server.tool("get_account_analytics", "Get account-level analytics (followers, subscribers, etc.) for connected social accounts. IMPORTANT metric semantics: account-level `impressions` for LinkedIn (profile and page) are LIFETIME cumulative totals across ALL of the account's content — including posts published outside OmniSocials — as of the snapshot date, NOT a daily or windowed count. They cannot be compared to a windowed export (e.g. LinkedIn's native 90-day analytics). Other platforms may not report account-level impressions at all. Each metrics object carries a `note` explaining its scope where semantics are non-obvious — always relay that note to the user.", {
|
|
199
205
|
platform: z.string().optional().describe("Filter by platform (e.g., instagram, youtube)"),
|
|
200
206
|
date: z.string().optional().describe("Date to get analytics for (YYYY-MM-DD, defaults to today)"),
|
|
201
207
|
}, async (params) => {
|
|
@@ -221,6 +227,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
221
227
|
const fb = b.metrics?.followers ?? 0;
|
|
222
228
|
return fb - fa;
|
|
223
229
|
});
|
|
230
|
+
const notes = [];
|
|
224
231
|
for (const a of sorted) {
|
|
225
232
|
const m = a.metrics || {};
|
|
226
233
|
const followers = m.followers !== undefined
|
|
@@ -245,6 +252,14 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
245
252
|
details.push(`${formatNumber(m.subscribers)} subscribers`);
|
|
246
253
|
}
|
|
247
254
|
md += `| ${capitalize(a.platform)} | ${followers} | ${details.join(", ") || "—"} |\n`;
|
|
255
|
+
// Scope notes (e.g. "LinkedIn impressions are lifetime totals") must
|
|
256
|
+
// reach the user, or lifetime counters get read as windowed numbers.
|
|
257
|
+
if (typeof m.note === "string" && m.note) {
|
|
258
|
+
notes.push(`- **${capitalize(a.platform)}:** ${m.note}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (notes.length) {
|
|
262
|
+
md += `\n${notes.join("\n")}\n`;
|
|
248
263
|
}
|
|
249
264
|
return {
|
|
250
265
|
content: [{ type: "text", text: md }],
|
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
|
|
@@ -427,7 +451,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
427
451
|
media_urls: z.union([
|
|
428
452
|
z.array(mediaUrlEntry),
|
|
429
453
|
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`."),
|
|
454
|
+
]).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
455
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
|
|
432
456
|
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
457
|
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."),
|
|
@@ -450,7 +474,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
450
474
|
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
451
475
|
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
452
476
|
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."),
|
|
477
|
+
}).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."),
|
|
454
478
|
youtube: z.object({
|
|
455
479
|
title: z.string().optional().describe("Short title shown on YouTube. Falls back to \"YouTube Short\" when omitted."),
|
|
456
480
|
tags: z.array(z.string()).optional(),
|
|
@@ -608,7 +632,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
608
632
|
media_urls: z.union([
|
|
609
633
|
z.array(mediaUrlEntry),
|
|
610
634
|
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."),
|
|
635
|
+
]).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
636
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
|
|
613
637
|
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
638
|
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."),
|
|
@@ -627,7 +651,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
627
651
|
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
628
652
|
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
629
653
|
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."),
|
|
654
|
+
}).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."),
|
|
631
655
|
youtube: z.object({
|
|
632
656
|
title: z.string().optional().describe("Short title shown on YouTube."),
|
|
633
657
|
tags: z.array(z.string()).optional(),
|
|
@@ -737,7 +761,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
737
761
|
});
|
|
738
762
|
server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
|
|
739
763
|
|
|
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.
|
|
764
|
+
**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
765
|
|
|
742
766
|
**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
767
|
id: z.string().describe("The post ID to update"),
|
|
@@ -751,7 +775,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
751
775
|
media_urls: z.union([
|
|
752
776
|
z.array(mediaUrlEntry),
|
|
753
777
|
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."),
|
|
778
|
+
]).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
779
|
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
780
|
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
781
|
user_tags: z.array(z.object({
|
|
@@ -776,7 +800,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
776
800
|
link: z.string().optional().describe("Destination URL the pin clicks through to"),
|
|
777
801
|
video_cover: z.string().optional().describe("Cover image URL for video pins (JPEG/PNG). Falls back to a video keyframe if omitted."),
|
|
778
802
|
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."),
|
|
803
|
+
}).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."),
|
|
780
804
|
instagram: z.object({
|
|
781
805
|
share_to_feed: z.boolean().optional(),
|
|
782
806
|
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."),
|
package/package.json
CHANGED