@omnisocials/mcp-server 1.27.0 → 1.28.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 +4 -4
- package/build/client.d.ts +28 -0
- package/build/index.js +1 -1
- package/build/tools/accounts.js +2 -2
- package/build/tools/analytics.js +5 -5
- package/build/tools/hashtag-sets.js +4 -4
- package/build/tools/inbox.js +4 -4
- package/build/tools/media.js +7 -7
- package/build/tools/posts.js +73 -17
- package/build/tools/webhooks.js +6 -6
- package/build/tools/workspaces.js +2 -2
- package/build/types.d.ts +2 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -108,10 +108,10 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
|
|
|
108
108
|
| `youtube` | YouTube | Shorts |
|
|
109
109
|
| `tiktok` | TikTok | Posts and reels |
|
|
110
110
|
| `pinterest` | Pinterest | Pins (requires `board_id`) |
|
|
111
|
-
| `x` | X (Twitter) | Posts |
|
|
112
|
-
| `threads` | Threads | Posts |
|
|
113
|
-
| `bluesky` | Bluesky | Posts |
|
|
114
|
-
| `mastodon` | Mastodon | Posts |
|
|
111
|
+
| `x` | X (Twitter) | Posts, chained threads via `x.thread_parts` |
|
|
112
|
+
| `threads` | Threads | Posts, chained threads via `threads.thread_parts` |
|
|
113
|
+
| `bluesky` | Bluesky | Posts, chained threads via `bluesky.thread_parts` |
|
|
114
|
+
| `mastodon` | Mastodon | Posts, chained threads via `mastodon.thread_parts` |
|
|
115
115
|
|
|
116
116
|
## Available Tools
|
|
117
117
|
|
package/build/client.d.ts
CHANGED
|
@@ -103,6 +103,28 @@ export interface MastodonPostOptions {
|
|
|
103
103
|
export interface MastodonPostOptionsUpdate {
|
|
104
104
|
thread_parts?: MastodonThreadPartInput[] | null;
|
|
105
105
|
}
|
|
106
|
+
export interface ThreadsThreadPartInput {
|
|
107
|
+
/** Part text, 1 to 500 characters. */
|
|
108
|
+
text: string;
|
|
109
|
+
/** Optional per-part media as Library IDs from upload_media (max 10,
|
|
110
|
+
* images and/or videos). Entries may be `{ id, alt }`. */
|
|
111
|
+
media_ids?: MediaIdEntry[];
|
|
112
|
+
/** Optional per-part media as external URLs (max 10, images and/or
|
|
113
|
+
* videos). Entries may be `{ url, alt }`. */
|
|
114
|
+
media_urls?: MediaUrlEntry[];
|
|
115
|
+
}
|
|
116
|
+
export interface ThreadsPostOptions {
|
|
117
|
+
/** Provide 2 to 25 parts to publish as a thread. Omit for a single post.
|
|
118
|
+
* When set, the Threads caption is taken from part 1. */
|
|
119
|
+
thread_parts?: ThreadsThreadPartInput[];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Update-side variant: `thread_parts: null` clears the thread (revert to a
|
|
123
|
+
* single post); `undefined` leaves the existing thread untouched.
|
|
124
|
+
*/
|
|
125
|
+
export interface ThreadsPostOptionsUpdate {
|
|
126
|
+
thread_parts?: ThreadsThreadPartInput[] | null;
|
|
127
|
+
}
|
|
106
128
|
/** Non-sponsored LinkedIn poll: question + 2-4 options + duration. */
|
|
107
129
|
export interface LinkedInPollFields {
|
|
108
130
|
question: string;
|
|
@@ -149,6 +171,7 @@ export interface CreatePostInput {
|
|
|
149
171
|
x?: XPostOptions;
|
|
150
172
|
bluesky?: BlueskyPostOptions;
|
|
151
173
|
mastodon?: MastodonPostOptions;
|
|
174
|
+
threads?: ThreadsPostOptions;
|
|
152
175
|
google_business?: Record<string, unknown>;
|
|
153
176
|
hashtag_set?: string;
|
|
154
177
|
hashtag_placement?: "caption_append" | "first_comment";
|
|
@@ -184,6 +207,10 @@ export declare class OmniSocialsClient {
|
|
|
184
207
|
media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
|
|
185
208
|
media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
|
|
186
209
|
type?: string;
|
|
210
|
+
link_url?: string;
|
|
211
|
+
link_title?: string;
|
|
212
|
+
link_description?: string;
|
|
213
|
+
link_thumbnail_url?: string;
|
|
187
214
|
location_id?: string;
|
|
188
215
|
collaborators?: string[];
|
|
189
216
|
user_tags?: Array<{
|
|
@@ -203,6 +230,7 @@ export declare class OmniSocialsClient {
|
|
|
203
230
|
x?: XPostOptionsUpdate;
|
|
204
231
|
bluesky?: BlueskyPostOptionsUpdate;
|
|
205
232
|
mastodon?: MastodonPostOptionsUpdate;
|
|
233
|
+
threads?: ThreadsPostOptionsUpdate;
|
|
206
234
|
google_business?: Record<string, unknown>;
|
|
207
235
|
}): Promise<ApiResponse<ApiPost>>;
|
|
208
236
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
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.28.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);
|
package/build/tools/accounts.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fetchImageAsBase64, capitalize } from "../client.js";
|
|
3
3
|
export function registerAccountTools(server, getClient) {
|
|
4
|
-
server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, async () => {
|
|
4
|
+
server.tool("list_accounts", "List all connected social media accounts in the workspace. Each account includes its platform, display name, channel ID (used for create_post), supported content_types (post, story, reel), and `needs_reconnect` (true when the OAuth token has been revoked/expired and the user must reconnect before posts can succeed; also reflected in `status` as `needs_reconnect` instead of `active`). Pinterest accounts include a `boards` array with `{id, name}` — use the board `id` as `board_id` when creating Pinterest posts. X accounts with Premium include `platform_details` with `subscription_type` (e.g. \"Premium\", \"PremiumPlus\"). LinkedIn appears as two independent platforms: `linkedin` for a personal profile and `linkedin_page` for a company page. A workspace can have both connected at the same time and post to each separately. Call this to help users pick which platforms to post to.", {}, { title: "List Connected Accounts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
5
5
|
const result = await getClient().listAccounts();
|
|
6
6
|
if (result.error) {
|
|
7
7
|
return {
|
|
@@ -63,7 +63,7 @@ export function registerAccountTools(server, getClient) {
|
|
|
63
63
|
});
|
|
64
64
|
server.tool("get_account", "Get details of a specific connected social media account.", {
|
|
65
65
|
id: z.string().describe("The account ID"),
|
|
66
|
-
}, async ({ id }) => {
|
|
66
|
+
}, { title: "Get Account Details", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
|
|
67
67
|
const result = await getClient().getAccount(id);
|
|
68
68
|
if (result.error) {
|
|
69
69
|
return {
|
package/build/tools/analytics.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getEngagement, getImpressions, getEngagementRate, MIN_IMPRESSIONS_FOR_E
|
|
|
4
4
|
export function registerAnalyticsTools(server, getClient) {
|
|
5
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. TikTok videos also carry average_time_watched, full_video_watched_rate, total_time_watched, favorites and reach when the workspace enabled TikTok comments (Business API authorization).", {
|
|
6
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."),
|
|
7
|
-
}, async ({ post_id }) => {
|
|
7
|
+
}, { title: "Get Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_id }) => {
|
|
8
8
|
const result = await getClient().getPostAnalytics(post_id);
|
|
9
9
|
if (result.error) {
|
|
10
10
|
return {
|
|
@@ -118,7 +118,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
118
118
|
.min(1)
|
|
119
119
|
.max(100)
|
|
120
120
|
.describe("Numeric OmniSocials post ids (the `id` field from list_posts), up to 100. NOT platform post ids such as YouTube video ids or tweet ids."),
|
|
121
|
-
}, async ({ post_ids }) => {
|
|
121
|
+
}, { title: "Get Bulk Post Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ post_ids }) => {
|
|
122
122
|
const result = await getClient().getPostsAnalytics(post_ids);
|
|
123
123
|
if (result.error) {
|
|
124
124
|
return {
|
|
@@ -177,7 +177,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
177
177
|
period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
|
|
178
178
|
start_date: z.string().optional().describe('Custom start date. Accepts "YYYY-MM-DD" (e.g. "2026-04-01") or "YYYY-MM" month-shorthand (e.g. "2026-04" → first of the month).'),
|
|
179
179
|
end_date: z.string().optional().describe('Custom end date. Same formats as start_date; "YYYY-MM" expands to the last day of the month.'),
|
|
180
|
-
}, async (params) => {
|
|
180
|
+
}, { title: "Get Analytics Overview", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
181
181
|
const result = await getClient().getAnalyticsOverview(params);
|
|
182
182
|
if (result.error) {
|
|
183
183
|
return {
|
|
@@ -226,7 +226,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
226
226
|
server.tool("get_account_analytics", "Get account-level analytics for connected social accounts: followers, following, posts, and the day values the platform reports for the snapshot date (impressions/views, reach, engagement, profile_views, link_clicks, follows_gained, follows_lost, Google Business calls/direction_requests/website_clicks/average_rating/review_count, Pinterest monthly_views). Rows with day values carry `period: \"daily\"`. Audience objects appear when available: `demographics` (+ demographics_unit) and `online_followers` (UTC hour to followers online). Metric semantics: LinkedIn profile rows keep a lifetime total under `impressions_lifetime` and `period: \"lifetime\"` when no daily breakdown is served. A missing key means the platform does not report it. Each metrics object carries a `note` explaining its scope where semantics are non-obvious; always relay that note to the user.", {
|
|
227
227
|
platform: z.string().optional().describe("Filter by platform (e.g., instagram, youtube)"),
|
|
228
228
|
date: z.string().optional().describe("Date to get analytics for (YYYY-MM-DD, defaults to today)"),
|
|
229
|
-
}, async (params) => {
|
|
229
|
+
}, { title: "Get Account Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
230
230
|
const result = await getClient().getAccountAnalytics(params);
|
|
231
231
|
if (result.error) {
|
|
232
232
|
return {
|
|
@@ -312,7 +312,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
312
312
|
server.tool("get_best_times", "Get recommended posting times (day + hour) for one platform, computed from the workspace's own posting history: publish time × engagement of every post published there, recency-weighted and outlier-damped, bucketed in the user's timezone, and blended with when the account's followers are online when the platform provides that (Instagram, TikTok Business; `basis: own_data_and_audience`, response carries `audience_online`). Returns the top 3 recommended slots plus a per-day breakdown. When the workspace has fewer than 15 analyzed posts on the platform, the audience-online profile alone is used (`basis: audience`) or industry-average defaults are returned (`basis: defaults`) with how many more posts unlock personalized recommendations — tell the user that so the numbers aren't mistaken for their own audience data. Use this before scheduling when the user asks 'when should I post?' or hasn't specified a time. Requires the analytics:read scope.", {
|
|
313
313
|
platform: z.string().describe("Platform identifier, e.g. instagram, tiktok, linkedin, linkedin_page, x, facebook, youtube, pinterest, threads, bluesky, mastodon, google_business"),
|
|
314
314
|
timezone: z.string().optional().describe("IANA timezone for the buckets (e.g. Europe/Amsterdam). Defaults to the account's timezone."),
|
|
315
|
-
}, async (params) => {
|
|
315
|
+
}, { title: "Get Best Times to Post", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
316
316
|
const result = await getClient().getBestTimes(params);
|
|
317
317
|
if (result.error) {
|
|
318
318
|
return {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export function registerHashtagSetTools(server, getClient) {
|
|
3
|
-
server.tool("list_hashtag_sets", "List the saved hashtag sets in this workspace. Apply one to a new post by passing its name via create_post (hashtag_set) — the tags are appended to the captions or, with hashtag_placement='first_comment', posted as the auto first comment.", {}, async () => {
|
|
3
|
+
server.tool("list_hashtag_sets", "List the saved hashtag sets in this workspace. Apply one to a new post by passing its name via create_post (hashtag_set) — the tags are appended to the captions or, with hashtag_placement='first_comment', posted as the auto first comment.", {}, { title: "List Hashtag Sets", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
4
4
|
const result = await getClient().listHashtagSets();
|
|
5
5
|
if (result.error) {
|
|
6
6
|
return {
|
|
@@ -28,7 +28,7 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
28
28
|
server.tool("create_hashtag_set", "Save a reusable, named group of hashtags (e.g. 'Fitness Brand' -> #fitness #gym #workout). Tags may include or omit the leading '#'; they are deduped case-insensitively and kept in order (max 100). Then apply the set to any new post via create_post (hashtag_set).", {
|
|
29
29
|
name: z.string().describe("Set name, unique per workspace, e.g. 'Fitness Brand'"),
|
|
30
30
|
hashtags: z.array(z.string()).describe('Tags in order, with or without the leading "#", e.g. ["fitness", "#gym", "workout"]'),
|
|
31
|
-
}, async (params) => {
|
|
31
|
+
}, { title: "Create Hashtag Set", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
32
32
|
const result = await getClient().createHashtagSet(params);
|
|
33
33
|
if (result.error) {
|
|
34
34
|
return {
|
|
@@ -52,7 +52,7 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
52
52
|
set_id: z.string().describe("The hashtag set id (from list_hashtag_sets)"),
|
|
53
53
|
name: z.string().optional().describe("New name for the set"),
|
|
54
54
|
hashtags: z.array(z.string()).optional().describe("Full replacement tag list, in order"),
|
|
55
|
-
}, async ({ set_id, name, hashtags }) => {
|
|
55
|
+
}, { title: "Update Hashtag Set", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ set_id, name, hashtags }) => {
|
|
56
56
|
const result = await getClient().updateHashtagSet(set_id, { name, hashtags });
|
|
57
57
|
if (result.error) {
|
|
58
58
|
return {
|
|
@@ -74,7 +74,7 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
74
74
|
});
|
|
75
75
|
server.tool("delete_hashtag_set", "Delete a saved hashtag set. Posts that already used it keep their hashtags — the tags were merged into their captions at create time.", {
|
|
76
76
|
set_id: z.string().describe("The hashtag set id (from list_hashtag_sets)"),
|
|
77
|
-
}, async ({ set_id }) => {
|
|
77
|
+
}, { title: "Delete Hashtag Set", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ set_id }) => {
|
|
78
78
|
const result = await getClient().deleteHashtagSet(set_id);
|
|
79
79
|
if (result.error) {
|
|
80
80
|
return {
|
package/build/tools/inbox.js
CHANGED
|
@@ -29,7 +29,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
29
29
|
.string()
|
|
30
30
|
.optional()
|
|
31
31
|
.describe("Pagination cursor from a previous response"),
|
|
32
|
-
}, async (params) => {
|
|
32
|
+
}, { title: "List Inbox Conversations", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
33
33
|
const query = {};
|
|
34
34
|
if (params.platform)
|
|
35
35
|
query.platform = params.platform;
|
|
@@ -78,7 +78,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
78
78
|
.optional()
|
|
79
79
|
.describe("Max messages per page (1-100, default 50)"),
|
|
80
80
|
cursor: z.string().optional().describe("Pagination cursor"),
|
|
81
|
-
}, async ({ conversation_id, limit, cursor }) => {
|
|
81
|
+
}, { title: "Get Inbox Conversation", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ conversation_id, limit, cursor }) => {
|
|
82
82
|
const query = {};
|
|
83
83
|
if (limit !== undefined)
|
|
84
84
|
query.limit = String(limit);
|
|
@@ -117,7 +117,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
117
117
|
});
|
|
118
118
|
server.tool("mark_inbox_read", "Mark all incoming messages in a conversation as read.", {
|
|
119
119
|
conversation_id: z.string().describe("The conversation ID to mark read"),
|
|
120
|
-
}, async ({ conversation_id }) => {
|
|
120
|
+
}, { title: "Mark Inbox Message Read", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ conversation_id }) => {
|
|
121
121
|
const result = await getClient().markInboxRead(conversation_id);
|
|
122
122
|
if (result.error) {
|
|
123
123
|
return {
|
|
@@ -143,7 +143,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
143
143
|
text: z
|
|
144
144
|
.string()
|
|
145
145
|
.describe("The reply text (max 2000 characters; TikTok comments max 150)"),
|
|
146
|
-
}, async ({ conversation_id, text }) => {
|
|
146
|
+
}, { title: "Reply to Inbox Message", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ conversation_id, text }) => {
|
|
147
147
|
const result = await getClient().replyToInboxConversation(conversation_id, {
|
|
148
148
|
text,
|
|
149
149
|
});
|
package/build/tools/media.js
CHANGED
|
@@ -6,7 +6,7 @@ export function registerMediaTools(server, getClient) {
|
|
|
6
6
|
offset: z.string().optional().describe("Offset for pagination"),
|
|
7
7
|
search: z.string().optional().describe('Find a file by name or filename, e.g. "play5get50". Use this before uploading to check if the graphic already exists.'),
|
|
8
8
|
folder_id: z.string().optional().describe('Only return media in this folder id (from list_folders). Use "root" for unfiled items.'),
|
|
9
|
-
}, async (params) => {
|
|
9
|
+
}, { title: "List Media Library", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
10
10
|
const result = await getClient().listMedia(params);
|
|
11
11
|
if (result.error) {
|
|
12
12
|
return {
|
|
@@ -72,7 +72,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
72
72
|
filename: z.string().optional().describe("Optional filename for the uploaded media"),
|
|
73
73
|
name: z.string().optional().describe('Human-readable label so you can find this asset by name later instead of re-uploading it, e.g. "pp-play5get50". Strongly recommended on every upload.'),
|
|
74
74
|
folder: z.string().optional().describe('Optional folder name to file this asset under (created at the top level if it does not exist), e.g. "win-graphics". See list_folders.'),
|
|
75
|
-
}, async (params) => {
|
|
75
|
+
}, { title: "Upload Media", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
76
76
|
let result;
|
|
77
77
|
if (params.base64_data && params.mime_type) {
|
|
78
78
|
result = await getClient().uploadMediaFromBase64({
|
|
@@ -147,7 +147,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
147
147
|
if (compatibility && compatibility.compatible === false && compatibility.summary) {
|
|
148
148
|
md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before adding it to a post.`;
|
|
149
149
|
}
|
|
150
|
-
md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted. To attach alt text (an accessibility description), pass \`{ id: "<Media ID>", alt: "..." }\` instead of the bare ID — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images).`;
|
|
150
|
+
md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`, \`bluesky.thread_parts[].media_ids\`, \`mastodon.thread_parts[].media_ids\` and \`threads.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted. To attach alt text (an accessibility description), pass \`{ id: "<Media ID>", alt: "..." }\` instead of the bare ID — delivered to Mastodon, Bluesky, X, Pinterest, Instagram (images) and LinkedIn (images).`;
|
|
151
151
|
return {
|
|
152
152
|
content: [{ type: "text", text: md }],
|
|
153
153
|
};
|
|
@@ -159,7 +159,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
159
159
|
media_id: z.string().optional().describe("Id of an already-uploaded library item to check."),
|
|
160
160
|
size_bytes: z.number().optional().describe("File size in bytes (use with mime when you already know them)."),
|
|
161
161
|
mime: z.string().optional().describe("MIME type, e.g. 'video/mp4' (use with size_bytes)."),
|
|
162
|
-
}, async (params) => {
|
|
162
|
+
}, { title: "Check Media Compatibility", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
163
163
|
if (!params.url && !params.media_id && !(params.size_bytes && params.mime)) {
|
|
164
164
|
return {
|
|
165
165
|
content: [{ type: "text", text: "Error: Provide one of 'url', 'media_id', or 'size_bytes' + 'mime'." }],
|
|
@@ -197,7 +197,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
197
197
|
id: z.string().describe("The media ID to update"),
|
|
198
198
|
name: z.string().optional().describe('New human-readable label, e.g. "pp-play5get50". Pass an empty string to clear it.'),
|
|
199
199
|
folder_id: z.string().optional().describe('Move the file into this folder id (from list_folders). Pass an empty string to move it to the root ("All media").'),
|
|
200
|
-
}, async ({ id, name, folder_id }) => {
|
|
200
|
+
}, { title: "Update Media", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, name, folder_id }) => {
|
|
201
201
|
const body = {};
|
|
202
202
|
if (name !== undefined)
|
|
203
203
|
body.name = name;
|
|
@@ -224,7 +224,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
224
224
|
],
|
|
225
225
|
};
|
|
226
226
|
});
|
|
227
|
-
server.tool("list_folders", "List the media-library folders in this workspace (Finder-style organization). Returns each folder's id, name, parent, and item count. Use a folder id with list_media (folder_id) or update_media (folder_id), or a folder name with upload_media (folder).", {}, async () => {
|
|
227
|
+
server.tool("list_folders", "List the media-library folders in this workspace (Finder-style organization). Returns each folder's id, name, parent, and item count. Use a folder id with list_media (folder_id) or update_media (folder_id), or a folder name with upload_media (folder).", {}, { title: "List Media Folders", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
228
228
|
const result = await getClient().listFolders();
|
|
229
229
|
if (result.error) {
|
|
230
230
|
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
@@ -243,7 +243,7 @@ Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an ex
|
|
|
243
243
|
server.tool("create_folder", "Create a media-library folder. Use it to organize assets (e.g. a 'win-graphics' folder), then upload into it with upload_media (folder) or move files with update_media (folder_id).", {
|
|
244
244
|
name: z.string().describe("Folder name, e.g. 'win-graphics'"),
|
|
245
245
|
parent_id: z.string().optional().describe("Optional parent folder id to nest under (from list_folders). Omit for a top-level folder."),
|
|
246
|
-
}, async ({ name, parent_id }) => {
|
|
246
|
+
}, { title: "Create Media Folder", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ name, parent_id }) => {
|
|
247
247
|
const result = await getClient().createFolder({ name, parent_id });
|
|
248
248
|
if (result.error) {
|
|
249
249
|
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
package/build/tools/posts.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
|
|
3
3
|
// Shared media entry schemas — every media list (flat arrays, per-platform
|
|
4
|
-
// records, and x/bluesky/mastodon thread parts) accepts either a plain string
|
|
4
|
+
// records, and x/bluesky/mastodon/threads thread parts) accepts either a plain string
|
|
5
5
|
// or an object carrying `alt`: an accessibility description (alt text, max
|
|
6
6
|
// 1500 chars) for that file. Alt text is delivered to Mastodon (media
|
|
7
7
|
// description — the community strongly values alt text), Bluesky (embed alt),
|
|
@@ -170,6 +170,11 @@ const MASTODON_THREAD_PART = z.object({
|
|
|
170
170
|
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)."),
|
|
171
171
|
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)."),
|
|
172
172
|
});
|
|
173
|
+
const THREADS_THREAD_PART = z.object({
|
|
174
|
+
text: z.string().min(1).max(500).describe("Part text (1 to 500 characters)."),
|
|
175
|
+
media_ids: z.array(mediaIdEntry).max(10).optional().describe("Per-part media as Library IDs from upload_media (max 10 combined with media_urls; images and videos can be mixed in one carousel). Each entry is a plain ID string or { id, alt }."),
|
|
176
|
+
media_urls: z.array(mediaUrlEntry).max(10).optional().describe("Per-part media as external URLs (max 10 combined with media_ids; images and videos can be mixed in one carousel). Each entry is a plain URL string or { url, alt }."),
|
|
177
|
+
});
|
|
173
178
|
const GOOGLE_BUSINESS_OPTIONS = z.object({
|
|
174
179
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
|
|
175
180
|
cta: z.object({
|
|
@@ -253,7 +258,7 @@ export function registerPostTools(server, getClient) {
|
|
|
253
258
|
status: z.string().optional().describe("Filter by status: draft, in_approval, scheduled, posting, published, failed, warning. in_approval = waiting for a reviewer in an approval workflow."),
|
|
254
259
|
limit: z.string().optional().describe("Max results to return (default: 20)"),
|
|
255
260
|
offset: z.string().optional().describe("Offset for pagination"),
|
|
256
|
-
}, async (params) => {
|
|
261
|
+
}, { title: "List Posts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
257
262
|
const result = await getClient().listPosts(params);
|
|
258
263
|
if (result.error) {
|
|
259
264
|
return {
|
|
@@ -277,13 +282,28 @@ export function registerPostTools(server, getClient) {
|
|
|
277
282
|
? (contentObj.default || Object.values(contentObj).find((v) => typeof v === "string" && v) || "")
|
|
278
283
|
: (typeof p.content === "string" ? p.content : "");
|
|
279
284
|
const hasOverrides = !!contentObj && Object.keys(contentObj).filter((k) => k !== "default" && typeof contentObj[k] === "string" && contentObj[k]).length > 0;
|
|
280
|
-
|
|
281
|
-
|
|
285
|
+
// Thread preview: the first thread platform carrying 2+ parts wins
|
|
286
|
+
// (X, Bluesky, Mastodon, Threads share one shape).
|
|
287
|
+
const threadSources = [
|
|
288
|
+
["X", p.x?.thread_parts],
|
|
289
|
+
["Bluesky", p.bluesky?.thread_parts],
|
|
290
|
+
["Mastodon", p.mastodon?.thread_parts],
|
|
291
|
+
["Threads", p.threads?.thread_parts],
|
|
292
|
+
];
|
|
293
|
+
let threadLabel = "";
|
|
294
|
+
let threadParts = [];
|
|
295
|
+
for (const [label, parts] of threadSources) {
|
|
296
|
+
if (Array.isArray(parts) && parts.length >= 2) {
|
|
297
|
+
threadLabel = label;
|
|
298
|
+
threadParts = parts;
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
282
302
|
const isThread = threadParts.length >= 2;
|
|
283
303
|
let content;
|
|
284
304
|
if (isThread && !rawContent) {
|
|
285
305
|
const first = typeof threadParts[0]?.text === "string" ? threadParts[0].text : "";
|
|
286
|
-
content = truncate(first, 32) + ` *(
|
|
306
|
+
content = truncate(first, 32) + ` *(${threadLabel} thread, ${threadParts.length} parts)*`;
|
|
287
307
|
}
|
|
288
308
|
else {
|
|
289
309
|
content = truncate(rawContent, hasOverrides ? 35 : 50) + (hasOverrides ? " *(per-platform)*" : "");
|
|
@@ -299,7 +319,7 @@ export function registerPostTools(server, getClient) {
|
|
|
299
319
|
});
|
|
300
320
|
server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs + any per-media alt text), first comment, Instagram collaborators/user tags/location/Trial Reel state/Reel cover (thumbnail_type + thumb_offset in ms), dates and live URLs, enough to fully verify a scheduled post without opening the dashboard. When a post has per-platform caption overrides (e.g. a shorter X version alongside the default), every variant is rendered as its own labeled block under `### Content` so you can see exactly what each platform will publish. X threads are rendered under `### X Thread` with each tweet labeled in publish order — read this to see the full chained tweet text, since thread-only posts have no caption in `content`. A LinkedIn poll is rendered under `### LinkedIn Profile Poll` and/or `### LinkedIn Page Poll` (independent per channel) with the question, options, and duration. After publishing, includes `published_urls` — a map of platform → live URL for each platform that successfully posted (e.g. facebook, instagram, linkedin, x). Useful for polling: when a post's status is `published`, read `published_urls` to surface the live links.", {
|
|
301
321
|
id: z.string().describe("The post ID"),
|
|
302
|
-
}, async ({ id }) => {
|
|
322
|
+
}, { title: "Get Post", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
|
|
303
323
|
const result = await getClient().getPost(id);
|
|
304
324
|
if (result.error) {
|
|
305
325
|
return {
|
|
@@ -430,6 +450,24 @@ export function registerPostTools(server, getClient) {
|
|
|
430
450
|
md += `\n`;
|
|
431
451
|
}
|
|
432
452
|
}
|
|
453
|
+
// Threads (Meta) thread: when present, the canonical post text lives here, not in `content`.
|
|
454
|
+
const rawThreadsParts = p.threads?.thread_parts;
|
|
455
|
+
const threadsThreadParts = Array.isArray(rawThreadsParts) ? rawThreadsParts : [];
|
|
456
|
+
if (threadsThreadParts.length >= 2) {
|
|
457
|
+
md += `\n\n### Threads Thread (${threadsThreadParts.length} parts)\n\n`;
|
|
458
|
+
for (let i = 0; i < threadsThreadParts.length; i++) {
|
|
459
|
+
const part = threadsThreadParts[i] || {};
|
|
460
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
461
|
+
md += `**${i + 1}/${threadsThreadParts.length}.** ${text}\n`;
|
|
462
|
+
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
463
|
+
for (const entry of part.media_urls) {
|
|
464
|
+
const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
|
|
465
|
+
md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
md += `\n`;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
433
471
|
// LinkedIn poll(s): independent per channel, mutually exclusive with
|
|
434
472
|
// media/link-share on that channel, so surface each prominently
|
|
435
473
|
// rather than leaving callers to infer it's a poll.
|
|
@@ -586,6 +624,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
586
624
|
- YouTube: Title, privacy status, tags?
|
|
587
625
|
- TikTok: Privacy level?
|
|
588
626
|
- **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.
|
|
627
|
+
- **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 4 media. Mastodon: 500 characters per part (instance-dependent), 4 media.
|
|
589
628
|
- **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.
|
|
590
629
|
|
|
591
630
|
Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
|
|
@@ -654,8 +693,11 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
654
693
|
mastodon: z.object({
|
|
655
694
|
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."),
|
|
656
695
|
}).optional().describe("Mastodon options"),
|
|
696
|
+
threads: z.object({
|
|
697
|
+
thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread instead of a single post. Provide 2 to 25 parts; parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids (from upload_media) or media_urls. When set, the Threads caption is taken from part 1 and content is ignored for Threads. For a single post, omit thread_parts and use content."),
|
|
698
|
+
}).optional().describe("Threads (Meta) options"),
|
|
657
699
|
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
658
|
-
}, async (params) => {
|
|
700
|
+
}, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
659
701
|
const result = await getClient().createPost({ ...params, source: "mcp" });
|
|
660
702
|
if (result.error) {
|
|
661
703
|
return {
|
|
@@ -732,7 +774,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
732
774
|
- **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
|
|
733
775
|
- **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.
|
|
734
776
|
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.
|
|
735
|
-
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.
|
|
777
|
+
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. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
|
|
736
778
|
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.
|
|
737
779
|
|
|
738
780
|
Do NOT call without required media — it will fail.`, {
|
|
@@ -747,6 +789,10 @@ Do NOT call without required media — it will fail.`, {
|
|
|
747
789
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
748
790
|
]).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."),
|
|
749
791
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (1 to 10 media items, each one a slide published in order), 'reel'"),
|
|
792
|
+
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)."),
|
|
793
|
+
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."),
|
|
794
|
+
link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
|
|
795
|
+
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)."),
|
|
750
796
|
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."),
|
|
751
797
|
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."),
|
|
752
798
|
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."),
|
|
@@ -794,8 +840,11 @@ Do NOT call without required media — it will fail.`, {
|
|
|
794
840
|
mastodon: z.object({
|
|
795
841
|
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."),
|
|
796
842
|
}).optional().describe("Mastodon options"),
|
|
843
|
+
threads: z.object({
|
|
844
|
+
thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread (2 to 25 parts). Parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids or media_urls. The Threads caption is taken from part 1."),
|
|
845
|
+
}).optional().describe("Threads (Meta) options"),
|
|
797
846
|
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
798
|
-
}, async (params) => {
|
|
847
|
+
}, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async (params) => {
|
|
799
848
|
const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
|
|
800
849
|
if (result.error) {
|
|
801
850
|
return {
|
|
@@ -843,7 +892,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
843
892
|
|
|
844
893
|
**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.
|
|
845
894
|
|
|
846
|
-
**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.`, {
|
|
895
|
+
**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. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
|
|
847
896
|
id: z.string().describe("The post ID to update"),
|
|
848
897
|
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\" }."),
|
|
849
898
|
scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled. A post waiting for approval keeps its in_approval status; only its time moves."),
|
|
@@ -856,6 +905,10 @@ Do NOT call without required media — it will fail.`, {
|
|
|
856
905
|
z.array(mediaUrlEntry),
|
|
857
906
|
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
858
907
|
]).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."),
|
|
908
|
+
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)."),
|
|
909
|
+
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."),
|
|
910
|
+
link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
|
|
911
|
+
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)."),
|
|
859
912
|
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."),
|
|
860
913
|
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."),
|
|
861
914
|
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."),
|
|
@@ -900,8 +953,11 @@ Do NOT call without required media — it will fail.`, {
|
|
|
900
953
|
mastodon: z.object({
|
|
901
954
|
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."),
|
|
902
955
|
}).optional().describe("Mastodon options"),
|
|
956
|
+
threads: z.object({
|
|
957
|
+
thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Threads (Meta) thread shape on this post. Pass an array (2 to 25 parts, 500 characters per part) to update/create the thread (attach up to 10 media to any part via media_ids or media_urls; images and videos can be mixed), or `null` to revert to single-post mode."),
|
|
958
|
+
}).optional().describe("Threads (Meta) options"),
|
|
903
959
|
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
904
|
-
}, async ({ id, ...data }) => {
|
|
960
|
+
}, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
|
|
905
961
|
const result = await getClient().updatePost(id, data);
|
|
906
962
|
if (result.error) {
|
|
907
963
|
return {
|
|
@@ -931,7 +987,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
931
987
|
});
|
|
932
988
|
server.tool("delete_post", "Delete a post by ID. This action cannot be undone.", {
|
|
933
989
|
id: z.string().describe("The post ID to delete"),
|
|
934
|
-
}, async ({ id }) => {
|
|
990
|
+
}, { title: "Delete Post", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
|
|
935
991
|
const result = await getClient().deletePost(id);
|
|
936
992
|
return {
|
|
937
993
|
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
|
|
@@ -941,7 +997,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
941
997
|
|
|
942
998
|
IMPORTANT: Before publishing, verify the post has all required media. If publishing a story or reel, ensure media was attached when the post was created/updated. If it's missing, use update_post to add media first, or inform the user.`, {
|
|
943
999
|
id: z.string().describe("The post ID to publish"),
|
|
944
|
-
}, async ({ id }) => {
|
|
1000
|
+
}, { title: "Publish Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
|
|
945
1001
|
const result = await getClient().publishPost(id);
|
|
946
1002
|
if (result.error) {
|
|
947
1003
|
return {
|
|
@@ -967,7 +1023,7 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
967
1023
|
|
|
968
1024
|
The retry runs asynchronously (usually within a few minutes). Poll \`get_post\` afterwards: on success the status becomes \`published\` and \`published_urls\` gains the platform's live URL; on another failure the platform's entry reappears under errors. Each platform can be retried at most 3 times — after that, recreate the post. Note \`publish_post\` refuses failed posts; this is the tool for them.`, {
|
|
969
1025
|
id: z.string().describe("The failed or partially failed post ID to retry"),
|
|
970
|
-
}, async ({ id }) => {
|
|
1026
|
+
}, { title: "Retry Failed Post", readOnlyHint: false, destructiveHint: false, openWorldHint: true }, async ({ id }) => {
|
|
971
1027
|
const result = await getClient().retryPost(id);
|
|
972
1028
|
if (result.error) {
|
|
973
1029
|
return {
|
|
@@ -1004,7 +1060,7 @@ Notes:
|
|
|
1004
1060
|
query: z
|
|
1005
1061
|
.string()
|
|
1006
1062
|
.describe("Place name to search (min 2 chars) — a dealership, café, venue, etc."),
|
|
1007
|
-
}, async ({ query }) => {
|
|
1063
|
+
}, { title: "Search Locations", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query }) => {
|
|
1008
1064
|
const result = await getClient().searchLocations(query);
|
|
1009
1065
|
const places = result?.data || [];
|
|
1010
1066
|
if (!places.length) {
|
|
@@ -1035,7 +1091,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
1035
1091
|
.enum(["music", "original_sound"])
|
|
1036
1092
|
.optional()
|
|
1037
1093
|
.describe("Catalog to search: licensed music (default) or original sounds created by Instagram users."),
|
|
1038
|
-
}, async ({ query, type }) => {
|
|
1094
|
+
}, { title: "Search Instagram Audio", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async ({ query, type }) => {
|
|
1039
1095
|
const result = await getClient().searchInstagramAudio(query, type);
|
|
1040
1096
|
const tracks = result?.data || [];
|
|
1041
1097
|
if (!tracks.length) {
|
|
@@ -1070,7 +1126,7 @@ Notes: only tracks Meta licenses for third-party publishing appear, so the selec
|
|
|
1070
1126
|
.string()
|
|
1071
1127
|
.optional()
|
|
1072
1128
|
.describe('Optional comma-separated platform filter, e.g. "instagram,tiktok". Defaults to every connected platform.'),
|
|
1073
|
-
}, async (params) => {
|
|
1129
|
+
}, { title: "Get Recent Platform Posts", readOnlyHint: true, destructiveHint: false, openWorldHint: true }, async (params) => {
|
|
1074
1130
|
const result = await getClient().getRecentPlatformPosts(params);
|
|
1075
1131
|
if (result.error) {
|
|
1076
1132
|
return {
|
package/build/tools/webhooks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatDateTime } from "../client.js";
|
|
3
3
|
export function registerWebhookTools(server, getClient) {
|
|
4
|
-
server.tool("list_webhooks", "List all configured webhooks.", {}, async () => {
|
|
4
|
+
server.tool("list_webhooks", "List all configured webhooks.", {}, { title: "List Webhooks", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
5
5
|
const result = await getClient().listWebhooks();
|
|
6
6
|
if (result.error) {
|
|
7
7
|
return {
|
|
@@ -31,7 +31,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
31
31
|
server.tool("create_webhook", "Create a new webhook to receive event notifications. Available events: post.scheduled, post.published, post.failed", {
|
|
32
32
|
url: z.string().describe("The HTTPS URL to send webhook events to"),
|
|
33
33
|
events: z.array(z.string()).describe("Events to subscribe to: post.scheduled, post.published, post.failed"),
|
|
34
|
-
}, async (params) => {
|
|
34
|
+
}, { title: "Create Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
35
35
|
const result = await getClient().createWebhook(params);
|
|
36
36
|
if (result.error) {
|
|
37
37
|
return {
|
|
@@ -61,7 +61,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
61
61
|
});
|
|
62
62
|
server.tool("get_webhook", "Get details of a specific webhook by ID.", {
|
|
63
63
|
id: z.string().describe("The webhook ID"),
|
|
64
|
-
}, async ({ id }) => {
|
|
64
|
+
}, { title: "Get Webhook", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async ({ id }) => {
|
|
65
65
|
const result = await getClient().getWebhook(id);
|
|
66
66
|
if (result.error) {
|
|
67
67
|
return {
|
|
@@ -97,7 +97,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
97
97
|
url: z.string().optional().describe("Updated HTTPS URL"),
|
|
98
98
|
events: z.array(z.string()).optional().describe("Updated event list"),
|
|
99
99
|
is_active: z.boolean().optional().describe("Enable or disable the webhook"),
|
|
100
|
-
}, async ({ id, ...data }) => {
|
|
100
|
+
}, { title: "Update Webhook", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
|
|
101
101
|
const result = await getClient().updateWebhook(id, data);
|
|
102
102
|
if (result.error) {
|
|
103
103
|
return {
|
|
@@ -123,7 +123,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
123
123
|
});
|
|
124
124
|
server.tool("delete_webhook", "Delete a webhook by ID. You will stop receiving notifications at this URL.", {
|
|
125
125
|
id: z.string().describe("The webhook ID to delete"),
|
|
126
|
-
}, async ({ id }) => {
|
|
126
|
+
}, { title: "Delete Webhook", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
|
|
127
127
|
const result = await getClient().deleteWebhook(id);
|
|
128
128
|
return {
|
|
129
129
|
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Webhook deleted successfully." }],
|
|
@@ -131,7 +131,7 @@ export function registerWebhookTools(server, getClient) {
|
|
|
131
131
|
});
|
|
132
132
|
server.tool("rotate_webhook_secret", "Rotate the signing secret for a webhook. The new secret will only be shown once.", {
|
|
133
133
|
id: z.string().describe("The webhook ID"),
|
|
134
|
-
}, async ({ id }) => {
|
|
134
|
+
}, { title: "Rotate Webhook Secret", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
|
|
135
135
|
const result = await getClient().rotateWebhookSecret(id);
|
|
136
136
|
if (result.error) {
|
|
137
137
|
return {
|
|
@@ -60,7 +60,7 @@ function resolveWorkspaceIndex(entries, query) {
|
|
|
60
60
|
return { notFound: true };
|
|
61
61
|
}
|
|
62
62
|
export function registerWorkspaceTools(server, workspaceClients, sessionState) {
|
|
63
|
-
server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, async () => {
|
|
63
|
+
server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, { title: "List Workspaces", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
64
64
|
const workspaces = await describeWorkspaces(workspaceClients, sessionState);
|
|
65
65
|
if (workspaces.length === 1) {
|
|
66
66
|
const ws = workspaces[0];
|
|
@@ -85,7 +85,7 @@ export function registerWorkspaceTools(server, workspaceClients, sessionState) {
|
|
|
85
85
|
workspace: z
|
|
86
86
|
.string()
|
|
87
87
|
.describe('The workspace to switch to: its name (preferred, e.g. "Daily Edge Sports"), or its id/list number.'),
|
|
88
|
-
}, async ({ workspace }) => {
|
|
88
|
+
}, { title: "Switch Workspace", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ workspace }) => {
|
|
89
89
|
const entries = await describeWorkspaces(workspaceClients, sessionState);
|
|
90
90
|
const resolved = resolveWorkspaceIndex(entries, workspace);
|
|
91
91
|
if ("ambiguous" in resolved) {
|
package/build/types.d.ts
CHANGED
|
@@ -72,7 +72,7 @@ export type ApiPostMediaEntry = string | {
|
|
|
72
72
|
alt?: string;
|
|
73
73
|
[k: string]: unknown;
|
|
74
74
|
};
|
|
75
|
-
/** A thread part as echoed back by the API (X / Bluesky / Mastodon). */
|
|
75
|
+
/** A thread part as echoed back by the API (X / Bluesky / Mastodon / Threads). */
|
|
76
76
|
export interface ApiThreadPartOut {
|
|
77
77
|
id?: string;
|
|
78
78
|
text?: string;
|
|
@@ -184,6 +184,7 @@ export interface ApiPost {
|
|
|
184
184
|
x?: ApiPlatformOptionsOut;
|
|
185
185
|
bluesky?: ApiPlatformOptionsOut;
|
|
186
186
|
mastodon?: ApiPlatformOptionsOut;
|
|
187
|
+
threads?: ApiPlatformOptionsOut;
|
|
187
188
|
/** platform → user-friendly error message; only failed platforms appear.
|
|
188
189
|
* null while draft/scheduled/processing or when every platform succeeded. */
|
|
189
190
|
errors?: Record<string, string> | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnisocials/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.28.0",
|
|
4
|
+
"mcpName": "io.github.omnisocials/mcp-server",
|
|
4
5
|
"description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "build/index.js",
|