@omnisocials/mcp-server 1.14.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -139,6 +139,17 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
139
139
  | `list_folders` | List the workspace's media folders |
140
140
  | `create_folder` | Create a media folder (optionally nested) |
141
141
 
142
+ ### Hashtag sets (4 tools)
143
+
144
+ | Tool | Description |
145
+ |------|-------------|
146
+ | `list_hashtag_sets` | List the workspace's saved hashtag sets |
147
+ | `create_hashtag_set` | Save a reusable, named group of hashtags (max 100 tags) |
148
+ | `update_hashtag_set` | Rename a set and/or replace its tags |
149
+ | `delete_hashtag_set` | Delete a set (existing posts keep their hashtags) |
150
+
151
+ Apply a set when creating a post: `create_post` / `create_and_publish_post` accept `hashtag_set` (the set name), `hashtag_placement` (`caption_append` default, or `first_comment` to keep tags out of the caption where supported), and `hashtag_platforms` (subset of channels).
152
+
142
153
  ### Accounts (2 tools)
143
154
 
144
155
  | Tool | Description |
@@ -207,6 +218,28 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
207
218
 
208
219
  ## Changelog
209
220
 
221
+ ### 1.17.0
222
+
223
+ - **Per-media alt text (accessibility descriptions):** `media_urls` / `media_ids` entries now accept `{ url, alt }` / `{ id, alt }` objects everywhere, including thread parts. Delivered to Mastodon (media description), Bluesky (embed alt), X (photos/GIFs), and Pinterest (pin `alt_text` fallback). `get_post` reads alt text back.
224
+
225
+ ### 1.16.0
226
+
227
+ - **Hashtag sets:** save reusable, named groups of hashtags per workspace and apply one to a new post in a single call. Four new tools: `list_hashtag_sets`, `create_hashtag_set`, `update_hashtag_set`, `delete_hashtag_set`.
228
+ - `create_post` / `create_and_publish_post` accept `hashtag_set`, `hashtag_placement` (`caption_append` default, or `first_comment`), and `hashtag_platforms`. The set expands server-side once at create time — editing a set later never changes existing posts; duplicate tags are skipped; Instagram's 30-hashtag cap fails fast with `hashtag_limit_exceeded`.
229
+ - Same tools on the companion server (`mcp.omnisocials.com`).
230
+
231
+ ### 1.15.0
232
+
233
+ - **Reel cover read-back:** `get_post` now renders the Instagram Reel cover selection (`thumbnail_type`, `thumb_offset` shown as ms and m:ss, `cover_url`), so a cover set through the API can be verified without opening the dashboard.
234
+ - `update_post` now accepts `instagram.thumbnail_type`, `instagram.thumb_offset`, and `instagram.cover_url` (previously create-only on this server).
235
+ - Tool schemas now document that `thumb_offset` is in **milliseconds** (e.g. `3000` = 0:03).
236
+ - Companion server (`mcp.omnisocials.com`): workspace name now always renders correctly in tool output instead of the "Workspace 1" placeholder when `get_post`/`list_posts` was the session's first call.
237
+
238
+ ### 1.14.0
239
+
240
+ - **`get_recent_platform_posts` rebuilt for ingesting native posts:** returns a `Structured data` JSON block with one record per post — the platform's own post/media `id` (stable de-dupe key), a `permalink`, the full untruncated caption, and exact-integer metrics (no more rounded `1.3K`).
241
+ - **`permalink`** added on every recent-platform post (all platforms except Google Business); also on the REST `GET /posts/recent-platform` response.
242
+
210
243
  ### 1.13.1
211
244
 
212
245
  - Docs-only republish to refresh the README on npmjs.com — the changelog below had drifted (it stopped at 1.5.0) and the Social Inbox tools were missing from the tool list. No code changes from 1.13.0.
package/build/client.d.ts CHANGED
@@ -17,13 +17,29 @@ export declare function sortMetricLabels(labels: string[]): string[];
17
17
  * back-compat key so a zero there is noise, not data.
18
18
  */
19
19
  export declare function metricRows(m: Record<string, unknown> | null | undefined): Array<[string, number]>;
20
+ /** A `media_urls` entry — a plain external URL, or `{ url, alt }` to attach
21
+ * an accessibility description (alt text, max 1500 chars) to that file. */
22
+ export type MediaUrlEntry = string | {
23
+ url: string;
24
+ alt?: string;
25
+ };
26
+ /** A `media_ids` entry — a plain Library media ID from upload_media, or
27
+ * `{ id, alt }` to attach an accessibility description (alt text, max 1500
28
+ * chars) to that file. */
29
+ export type MediaIdEntry = string | {
30
+ id: string;
31
+ alt?: string;
32
+ };
20
33
  export interface XThreadPartInput {
21
34
  /** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
22
35
  text: string;
23
- /** Optional per-part media as Library IDs from upload_media (max 4 combined with media_urls). */
24
- media_ids?: string[];
25
- /** Optional per-part media as external URLs (max 4 combined with media_ids). */
26
- media_urls?: string[];
36
+ /** Optional per-part media as Library IDs from upload_media (max 4 combined
37
+ * with media_urls). Entries may be `{ id, alt }` to attach alt text (X
38
+ * applies it to photos/GIFs). */
39
+ media_ids?: MediaIdEntry[];
40
+ /** Optional per-part media as external URLs (max 4 combined with media_ids).
41
+ * Entries may be `{ url, alt }` to attach alt text (photos/GIFs only). */
42
+ media_urls?: MediaUrlEntry[];
27
43
  }
28
44
  export interface XPostOptions {
29
45
  reply_settings?: "" | "following" | "mentionedUsers";
@@ -43,10 +59,12 @@ export interface XPostOptionsUpdate extends Omit<XPostOptions, "thread_parts"> {
43
59
  export interface BlueskyThreadPartInput {
44
60
  /** Post text — ≤ 300 characters (counted as graphemes; one emoji = 1). */
45
61
  text: string;
46
- /** Optional per-part media as Library IDs from upload_media (max 4). */
47
- media_ids?: string[];
48
- /** Optional per-part media as external URLs (max 4). */
49
- media_urls?: string[];
62
+ /** Optional per-part media as Library IDs from upload_media (max 4).
63
+ * Entries may be `{ id, alt }` to set the image's embed alt on Bluesky. */
64
+ media_ids?: MediaIdEntry[];
65
+ /** Optional per-part media as external URLs (max 4). Entries may be
66
+ * `{ url, alt }` to set the image's embed alt on Bluesky. */
67
+ media_urls?: MediaUrlEntry[];
50
68
  }
51
69
  export interface BlueskyPostOptions {
52
70
  /** Provide 2–25 parts to publish as a thread. Omit for a single post. */
@@ -62,10 +80,14 @@ export interface BlueskyPostOptionsUpdate {
62
80
  export interface MastodonThreadPartInput {
63
81
  /** Status text — ≤ 500 characters by default (some instances allow more). */
64
82
  text: string;
65
- /** Optional per-part media as Library IDs from upload_media (max 4). */
66
- media_ids?: string[];
67
- /** Optional per-part media as external URLs (max 4). */
68
- media_urls?: string[];
83
+ /** Optional per-part media as Library IDs from upload_media (max 4).
84
+ * Entries may be `{ id, alt }` to set the media description — the Mastodon
85
+ * community strongly values alt text on images. */
86
+ media_ids?: MediaIdEntry[];
87
+ /** Optional per-part media as external URLs (max 4). Entries may be
88
+ * `{ url, alt }` to set the media description — the Mastodon community
89
+ * strongly values alt text on images. */
90
+ media_urls?: MediaUrlEntry[];
69
91
  }
70
92
  export interface MastodonPostOptions {
71
93
  /** Provide 2–25 parts to publish as a thread. Omit for a single status. */
@@ -97,8 +119,8 @@ export declare class OmniSocialsClient {
97
119
  content: string | Record<string, string>;
98
120
  channels?: string[];
99
121
  scheduled_at?: string;
100
- media_ids?: string[] | Record<string, string[]>;
101
- media_urls?: string[] | Record<string, string[]>;
122
+ media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
123
+ media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
102
124
  type?: string;
103
125
  source?: string;
104
126
  link_url?: string;
@@ -124,12 +146,15 @@ export declare class OmniSocialsClient {
124
146
  bluesky?: BlueskyPostOptions;
125
147
  mastodon?: MastodonPostOptions;
126
148
  google_business?: Record<string, unknown>;
149
+ hashtag_set?: string;
150
+ hashtag_placement?: "caption_append" | "first_comment";
151
+ hashtag_platforms?: string[];
127
152
  }): Promise<ApiResponse<unknown>>;
128
153
  createAndPublishPost(data: {
129
154
  content: string | Record<string, string>;
130
155
  channels?: string[];
131
- media_ids?: string[] | Record<string, string[]>;
132
- media_urls?: string[] | Record<string, string[]>;
156
+ media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
157
+ media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
133
158
  type?: string;
134
159
  source?: string;
135
160
  link_url?: string;
@@ -155,13 +180,16 @@ export declare class OmniSocialsClient {
155
180
  bluesky?: BlueskyPostOptions;
156
181
  mastodon?: MastodonPostOptions;
157
182
  google_business?: Record<string, unknown>;
183
+ hashtag_set?: string;
184
+ hashtag_placement?: "caption_append" | "first_comment";
185
+ hashtag_platforms?: string[];
158
186
  }): Promise<ApiResponse<unknown>>;
159
187
  updatePost(id: string, data: {
160
188
  content?: string | Record<string, string>;
161
189
  scheduled_at?: string;
162
190
  channels?: string[];
163
- media_ids?: string[] | Record<string, string[]>;
164
- media_urls?: string[] | Record<string, string[]>;
191
+ media_ids?: MediaIdEntry[] | Record<string, MediaIdEntry[]>;
192
+ media_urls?: MediaUrlEntry[] | Record<string, MediaUrlEntry[]>;
165
193
  type?: string;
166
194
  location_id?: string;
167
195
  collaborators?: string[];
@@ -234,6 +262,16 @@ export declare class OmniSocialsClient {
234
262
  name: string;
235
263
  parent_id?: string;
236
264
  }): Promise<ApiResponse<unknown>>;
265
+ listHashtagSets(): Promise<ApiResponse<unknown>>;
266
+ createHashtagSet(data: {
267
+ name: string;
268
+ hashtags: string[];
269
+ }): Promise<ApiResponse<unknown>>;
270
+ updateHashtagSet(id: string, data: {
271
+ name?: string;
272
+ hashtags?: string[];
273
+ }): Promise<ApiResponse<unknown>>;
274
+ deleteHashtagSet(id: string): Promise<ApiResponse<unknown>>;
237
275
  listAccounts(): Promise<ApiResponse<unknown>>;
238
276
  getAccount(id: string): Promise<ApiResponse<unknown>>;
239
277
  getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
package/build/client.js CHANGED
@@ -272,6 +272,19 @@ export class OmniSocialsClient {
272
272
  async createFolder(data) {
273
273
  return this.request("POST", "/folders", data);
274
274
  }
275
+ // Hashtag Sets
276
+ async listHashtagSets() {
277
+ return this.request("GET", "/hashtag-sets");
278
+ }
279
+ async createHashtagSet(data) {
280
+ return this.request("POST", "/hashtag-sets", data);
281
+ }
282
+ async updateHashtagSet(id, data) {
283
+ return this.request("PATCH", `/hashtag-sets/${id}`, data);
284
+ }
285
+ async deleteHashtagSet(id) {
286
+ return this.request("DELETE", `/hashtag-sets/${id}`);
287
+ }
275
288
  // Accounts
276
289
  async listAccounts() {
277
290
  return this.request("GET", "/accounts");
package/build/index.js CHANGED
@@ -8,6 +8,7 @@ import { registerMediaTools } from "./tools/media.js";
8
8
  import { registerAccountTools } from "./tools/accounts.js";
9
9
  import { registerAnalyticsTools } from "./tools/analytics.js";
10
10
  import { registerWebhookTools } from "./tools/webhooks.js";
11
+ import { registerHashtagSetTools } from "./tools/hashtag-sets.js";
11
12
  import { registerInboxTools } from "./tools/inbox.js";
12
13
  import { registerWorkspaceTools } from "./tools/workspaces.js";
13
14
  const apiKeyEnv = process.env.OMNISOCIALS_API_KEY;
@@ -28,7 +29,7 @@ const sessionState = { activeIndex: 0 };
28
29
  const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
29
30
  const server = new McpServer({
30
31
  name: "OmniSocials",
31
- version: "1.13.1",
32
+ version: "1.17.0",
32
33
  });
33
34
  // Register all tools - pass getter function so tools always use the active workspace's client
34
35
  registerPostTools(server, getActiveClient);
@@ -36,6 +37,7 @@ registerMediaTools(server, getActiveClient);
36
37
  registerAccountTools(server, getActiveClient);
37
38
  registerAnalyticsTools(server, getActiveClient);
38
39
  registerWebhookTools(server, getActiveClient);
40
+ registerHashtagSetTools(server, getActiveClient);
39
41
  registerInboxTools(server, getActiveClient);
40
42
  registerWorkspaceTools(server, workspaceClients, sessionState);
41
43
  // Register prompts
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { OmniSocialsClient } from "../client.js";
3
+ export declare function registerHashtagSetTools(server: McpServer, getClient: () => OmniSocialsClient): void;
@@ -0,0 +1,78 @@
1
+ import { z } from "zod";
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 () => {
4
+ const result = await getClient().listHashtagSets();
5
+ if (result.error) {
6
+ return {
7
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
8
+ };
9
+ }
10
+ const sets = Array.isArray(result.data) ? result.data : result.data?.data || [];
11
+ if (!sets.length) {
12
+ return {
13
+ content: [{ type: "text", text: "No hashtag sets yet. Create one with create_hashtag_set." }],
14
+ };
15
+ }
16
+ let md = `## Hashtag Sets (${sets.length})\n\n`;
17
+ md += `| Set ID | Name | Tags | Preview |\n`;
18
+ md += `|--------|------|------|---------|\n`;
19
+ for (const s of sets) {
20
+ const preview = (s.preview || "").length > 80 ? `${s.preview.slice(0, 77)}…` : s.preview || "";
21
+ md += `| \`${s.id}\` | ${s.name} | ${s.hashtag_count} | ${preview} |\n`;
22
+ }
23
+ md += `\nApply a set with create_post (hashtag_set="<name>"). Edit with update_hashtag_set.`;
24
+ return {
25
+ content: [{ type: "text", text: md }],
26
+ };
27
+ });
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
+ name: z.string().describe("Set name, unique per workspace, e.g. 'Fitness Brand'"),
30
+ hashtags: z.array(z.string()).describe('Tags in order, with or without the leading "#", e.g. ["fitness", "#gym", "workout"]'),
31
+ }, async (params) => {
32
+ const result = await getClient().createHashtagSet(params);
33
+ if (result.error) {
34
+ return {
35
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
36
+ };
37
+ }
38
+ const s = result.data;
39
+ return {
40
+ content: [{
41
+ type: "text",
42
+ text: `Created hashtag set \`${s.id}\` "${s.name}" (${s.hashtag_count} tags): ${s.preview}\n\nApply it with create_post (hashtag_set="${s.name}").`,
43
+ }],
44
+ };
45
+ });
46
+ server.tool("update_hashtag_set", "Rename a hashtag set and/or replace its tags. 'hashtags' replaces the FULL list — to add or remove tags, pass the complete new list (see list_hashtag_sets for the current tags). Posts that already used the set are unaffected.", {
47
+ set_id: z.string().describe("The hashtag set id (from list_hashtag_sets)"),
48
+ name: z.string().optional().describe("New name for the set"),
49
+ hashtags: z.array(z.string()).optional().describe("Full replacement tag list, in order"),
50
+ }, async ({ set_id, name, hashtags }) => {
51
+ const result = await getClient().updateHashtagSet(set_id, { name, hashtags });
52
+ if (result.error) {
53
+ return {
54
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
55
+ };
56
+ }
57
+ const s = result.data;
58
+ return {
59
+ content: [{
60
+ type: "text",
61
+ text: `Updated hashtag set \`${s.id}\` "${s.name}" (${s.hashtag_count} tags): ${s.preview}`,
62
+ }],
63
+ };
64
+ });
65
+ 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.", {
66
+ set_id: z.string().describe("The hashtag set id (from list_hashtag_sets)"),
67
+ }, async ({ set_id }) => {
68
+ const result = await getClient().deleteHashtagSet(set_id);
69
+ if (result.error) {
70
+ return {
71
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
72
+ };
73
+ }
74
+ return {
75
+ content: [{ type: "text", text: `Deleted hashtag set \`${set_id}\`.` }],
76
+ };
77
+ });
78
+ }
@@ -142,7 +142,7 @@ When the user provides an image in the conversation (not a URL), use base64_data
142
142
  if (compatibility && compatibility.compatible === false && compatibility.summary) {
143
143
  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.`;
144
144
  }
145
- 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.`;
145
+ 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 (photos/GIFs), and Pinterest.`;
146
146
  return {
147
147
  content: [{ type: "text", text: md }],
148
148
  };
@@ -1,5 +1,27 @@
1
1
  import { z } from "zod";
2
2
  import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
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
5
+ // or an object carrying `alt`: an accessibility description (alt text, max
6
+ // 1500 chars) for that file. Alt text is delivered to Mastodon (media
7
+ // description — the community strongly values alt text), Bluesky (embed alt),
8
+ // X (photos/GIFs only), and Pinterest (used as the pin alt_text when
9
+ // pinterest.alt_text is not set); other platforms ignore it for now.
10
+ const ALT_TEXT_DESCRIBE = "Accessibility description (alt text) for this file, max 1500 chars. Delivered to Mastodon (media description), Bluesky (embed alt), X (photos/GIFs only), and Pinterest (used as the pin alt_text when pinterest.alt_text is not set); other platforms ignore it.";
11
+ const mediaUrlEntry = z.union([
12
+ z.string(),
13
+ z.object({
14
+ url: z.string().describe("External image/video URL."),
15
+ alt: z.string().max(1500).optional().describe(ALT_TEXT_DESCRIBE),
16
+ }),
17
+ ]).describe("External media URL — a plain string, or { url, alt } to attach an accessibility description (alt text).");
18
+ const mediaIdEntry = z.union([
19
+ z.string(),
20
+ z.object({
21
+ id: z.string().describe("Library media ID from upload_media."),
22
+ alt: z.string().max(1500).optional().describe(ALT_TEXT_DESCRIBE),
23
+ }),
24
+ ]).describe("Library media ID — a plain string, or { id, alt } to attach an accessibility description (alt text).");
3
25
  // Reusable per-platform option objects for the "first comment" feature — text
4
26
  // auto-posted as a comment on the post right after it publishes (hashtags out
5
27
  // of the caption, "link in first comment", etc.). Only platforms with a
@@ -140,7 +162,7 @@ export function registerPostTools(server, getClient) {
140
162
  content: [{ type: "text", text: md }],
141
163
  };
142
164
  });
143
- server.tool("get_post", "Get details of a specific post by ID — content, channels, media (with URLs), first comment, Instagram collaborators/user tags/location/Trial Reel state, 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`. 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.", {
165
+ 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`. 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.", {
144
166
  id: z.string().describe("The post ID"),
145
167
  }, async ({ id }) => {
146
168
  const result = await getClient().getPost(id);
@@ -204,7 +226,8 @@ export function registerPostTools(server, getClient) {
204
226
  md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
205
227
  }
206
228
  items.forEach((m, i) => {
207
- md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}\n`;
229
+ const alt = typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
230
+ md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}${alt}\n`;
208
231
  });
209
232
  md += `\n`;
210
233
  }
@@ -221,8 +244,10 @@ export function registerPostTools(server, getClient) {
221
244
  const text = typeof part.text === "string" ? part.text : "";
222
245
  md += `**${i + 1}/${threadParts.length}.** ${text}\n`;
223
246
  if (Array.isArray(part.media_urls) && part.media_urls.length) {
224
- for (const url of part.media_urls)
225
- md += ` - ${url}\n`;
247
+ for (const entry of part.media_urls) {
248
+ const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
249
+ md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
250
+ }
226
251
  }
227
252
  md += `\n`;
228
253
  }
@@ -238,8 +263,10 @@ export function registerPostTools(server, getClient) {
238
263
  const text = typeof part.text === "string" ? part.text : "";
239
264
  md += `**${i + 1}/${bskyThreadParts.length}.** ${text}\n`;
240
265
  if (Array.isArray(part.media_urls) && part.media_urls.length) {
241
- for (const url of part.media_urls)
242
- md += ` - ${url}\n`;
266
+ for (const entry of part.media_urls) {
267
+ const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
268
+ md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
269
+ }
243
270
  }
244
271
  md += `\n`;
245
272
  }
@@ -255,8 +282,10 @@ export function registerPostTools(server, getClient) {
255
282
  const text = typeof part.text === "string" ? part.text : "";
256
283
  md += `**${i + 1}/${mastoThreadParts.length}.** ${text}\n`;
257
284
  if (Array.isArray(part.media_urls) && part.media_urls.length) {
258
- for (const url of part.media_urls)
259
- md += ` - ${url}\n`;
285
+ for (const entry of part.media_urls) {
286
+ const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
287
+ md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
288
+ }
260
289
  }
261
290
  md += `\n`;
262
291
  }
@@ -300,6 +329,20 @@ export function registerPostTools(server, getClient) {
300
329
  : "manual (graduate it yourself in the Instagram app)";
301
330
  md += `\n\n**Instagram Trial Reel:** enabled — shown to non-followers first; graduation: ${strategy}\n`;
302
331
  }
332
+ // Reel cover selection read-back (thumb_offset is in MILLISECONDS).
333
+ // The API defaults these to ""/0 when never set, so only render values
334
+ // that were actually chosen.
335
+ if (igOpts && typeof igOpts === "object") {
336
+ const offsetMs = Number(igOpts.thumb_offset) || 0;
337
+ if (igOpts.thumbnail_type === "from-library" && igOpts.cover_url) {
338
+ md += `\n**Instagram Reel cover:** custom image — ${igOpts.cover_url}\n`;
339
+ }
340
+ else if (igOpts.thumbnail_type === "from-video" || offsetMs > 0) {
341
+ const totalSec = Math.floor(offsetMs / 1000);
342
+ const mmss = `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`;
343
+ md += `\n**Instagram Reel cover:** video frame at ${mmss} (thumb_offset ${offsetMs} ms)\n`;
344
+ }
345
+ }
303
346
  return {
304
347
  content: [{ type: "text", text: md }],
305
348
  };
@@ -353,13 +396,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
353
396
  channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
354
397
  scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing"),
355
398
  media_ids: z.union([
356
- z.array(z.string()),
357
- z.record(z.string(), z.array(z.string())),
358
- ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }"),
399
+ z.array(mediaIdEntry),
400
+ z.record(z.string(), z.array(mediaIdEntry)),
401
+ ]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }. Each entry is a plain ID string or { id, alt } to attach alt text (accessibility description, max 1500 chars) to that file — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest."),
359
402
  media_urls: z.union([
360
- z.array(z.string()),
361
- z.record(z.string(), z.array(z.string())),
362
- ]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], pinterest: [...] }. Max 10 total. When using per-platform format, 'default' is the fallback for selected platforms without their own key. Pass an empty array (e.g. facebook: []) to opt a platform out of media."),
403
+ z.array(mediaUrlEntry),
404
+ z.record(z.string(), z.array(mediaUrlEntry)),
405
+ ]).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 (photos/GIFs), and Pinterest. 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`."),
363
406
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
364
407
  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)."),
365
408
  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."),
@@ -373,6 +416,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
373
416
  y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
374
417
  image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
375
418
  })).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."),
419
+ hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 30-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
420
+ hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
421
+ hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
376
422
  pinterest: z.object({
377
423
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
378
424
  title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
@@ -392,9 +438,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
392
438
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
393
439
  instagram: z.object({
394
440
  share_to_feed: z.boolean().optional(),
395
- thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
396
- thumb_offset: z.number().optional(),
397
- cover_url: z.string().optional(),
441
+ 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."),
442
+ thumb_offset: z.number().optional().describe("Reel cover frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
443
+ cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
398
444
  audio_id: z.string().optional().describe("Reels only. Licensed music track to attach — a numeric audio ID from search_instagram_audio. Requires a Facebook account connected to the workspace whose Page links this Instagram account."),
399
445
  audio_volume: z.number().int().min(0).max(100).optional().describe("Volume of the attached music track, 0-100 (default 100). Only used with audio_id."),
400
446
  video_volume: z.number().int().min(0).max(100).optional().describe("Volume of the video's own audio, 0-100 (default 100). Set 0 for a music-only Reel. Only used with audio_id."),
@@ -422,22 +468,22 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
422
468
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
423
469
  thread_parts: z.array(z.object({
424
470
  text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
425
- media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
426
- media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
471
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
472
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
427
473
  })).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
428
474
  }).optional().describe("X (Twitter) options"),
429
475
  bluesky: z.object({
430
476
  thread_parts: z.array(z.object({
431
477
  text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
432
- media_ids: z.array(z.string()).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images."),
433
- media_urls: z.array(z.string()).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images."),
478
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
479
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
434
480
  })).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 4 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
435
481
  }).optional().describe("Bluesky options"),
436
482
  mastodon: z.object({
437
483
  thread_parts: z.array(z.object({
438
484
  text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
439
- media_ids: z.array(z.string()).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4)."),
440
- media_urls: z.array(z.string()).max(4).optional().describe("Per-status media as external URLs (max 4)."),
485
+ 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)."),
486
+ 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)."),
441
487
  })).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."),
442
488
  }).optional().describe("Mastodon options"),
443
489
  google_business: z.object({
@@ -531,13 +577,13 @@ Do NOT call without required media — it will fail.`, {
531
577
  content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
532
578
  channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
533
579
  media_ids: z.union([
534
- z.array(z.string()),
535
- z.record(z.string(), z.array(z.string())),
536
- ]).optional().describe("Media IDs from upload — flat array or per-platform object"),
580
+ z.array(mediaIdEntry),
581
+ z.record(z.string(), z.array(mediaIdEntry)),
582
+ ]).optional().describe("Media IDs from upload — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest."),
537
583
  media_urls: z.union([
538
- z.array(z.string()),
539
- z.record(z.string(), z.array(z.string())),
540
- ]).optional().describe("External image/video URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
584
+ z.array(mediaUrlEntry),
585
+ z.record(z.string(), z.array(mediaUrlEntry)),
586
+ ]).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 (photos/GIFs), and Pinterest. 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."),
541
587
  type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
542
588
  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."),
543
589
  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."),
@@ -547,6 +593,9 @@ Do NOT call without required media — it will fail.`, {
547
593
  y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
548
594
  image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
549
595
  })).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."),
596
+ hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. Tags are merged in once at create time; tags already in a caption are skipped."),
597
+ hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("caption_append (default) appends tags to the captions; first_comment posts them as the auto first comment on comment-capable platforms (others fall back to caption)."),
598
+ hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
550
599
  pinterest: z.object({
551
600
  board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
552
601
  title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
@@ -561,6 +610,9 @@ Do NOT call without required media — it will fail.`, {
561
610
  first_comment: z.string().max(10000).optional().describe("Text auto-posted as the first comment on the video right after it publishes. The video must have comments enabled."),
562
611
  }).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
563
612
  instagram: z.object({
613
+ 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."),
614
+ thumb_offset: z.number().optional().describe("Reel cover frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
615
+ cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
564
616
  audio_id: z.string().optional().describe("Reels only. Licensed music track to attach — a numeric audio ID from search_instagram_audio. Requires a Facebook account connected to the workspace whose Page links this Instagram account."),
565
617
  audio_volume: z.number().int().min(0).max(100).optional().describe("Volume of the attached music track, 0-100 (default 100). Only used with audio_id."),
566
618
  video_volume: z.number().int().min(0).max(100).optional().describe("Volume of the video's own audio, 0-100 (default 100). Set 0 for a music-only Reel. Only used with audio_id."),
@@ -588,22 +640,22 @@ Do NOT call without required media — it will fail.`, {
588
640
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
589
641
  thread_parts: z.array(z.object({
590
642
  text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
591
- media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
592
- media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
643
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
644
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
593
645
  })).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
594
646
  }).optional().describe("X (Twitter) options"),
595
647
  bluesky: z.object({
596
648
  thread_parts: z.array(z.object({
597
649
  text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
598
- media_ids: z.array(z.string()).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images."),
599
- media_urls: z.array(z.string()).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images."),
650
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
651
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
600
652
  })).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 4 images per part. Links, mentions and hashtags are made clickable automatically."),
601
653
  }).optional().describe("Bluesky options"),
602
654
  mastodon: z.object({
603
655
  thread_parts: z.array(z.object({
604
656
  text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
605
- media_ids: z.array(z.string()).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4)."),
606
- media_urls: z.array(z.string()).max(4).optional().describe("Per-status media as external URLs (max 4)."),
657
+ 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)."),
658
+ 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)."),
607
659
  })).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."),
608
660
  }).optional().describe("Mastodon options"),
609
661
  google_business: z.object({
@@ -668,13 +720,13 @@ Do NOT call without required media — it will fail.`, {
668
720
  scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
669
721
  channels: z.array(z.string()).optional().describe("Updated channel IDs. Bare platform name or composite `\"<workspace_id>_<platform>\"`; always source from list_accounts for this API key (unknown or mismatched workspace prefixes are rejected as unknown accounts). Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
670
722
  media_ids: z.union([
671
- z.array(z.string()),
672
- z.record(z.string(), z.array(z.string())),
673
- ]).optional().describe("Media IDs — flat array or per-platform object"),
723
+ z.array(mediaIdEntry),
724
+ z.record(z.string(), z.array(mediaIdEntry)),
725
+ ]).optional().describe("Media IDs — flat array or per-platform object. Each entry is a plain ID string or { id, alt } to attach alt text — delivered to Mastodon, Bluesky, X (photos/GIFs), and Pinterest."),
674
726
  media_urls: z.union([
675
- z.array(z.string()),
676
- z.record(z.string(), z.array(z.string())),
677
- ]).optional().describe("External URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
727
+ z.array(mediaUrlEntry),
728
+ z.record(z.string(), z.array(mediaUrlEntry)),
729
+ ]).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 (photos/GIFs), and Pinterest. 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."),
678
730
  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."),
679
731
  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."),
680
732
  user_tags: z.array(z.object({
@@ -702,9 +754,9 @@ Do NOT call without required media — it will fail.`, {
702
754
  }).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."),
703
755
  instagram: z.object({
704
756
  share_to_feed: z.boolean().optional(),
705
- thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
706
- thumb_offset: z.number().optional(),
707
- cover_url: z.string().optional(),
757
+ 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."),
758
+ thumb_offset: z.number().optional().describe("Reel cover frame timestamp in MILLISECONDS from the start of the video (e.g. 3000 = 0:03). Used with thumbnail_type 'from-video'."),
759
+ cover_url: z.string().optional().describe("Custom Reel cover image URL. Used with thumbnail_type 'from-library'."),
708
760
  audio_id: z.string().optional().describe("Reels only. Licensed music track to attach — a numeric audio ID from search_instagram_audio. Requires a Facebook account connected to the workspace whose Page links this Instagram account."),
709
761
  audio_volume: z.number().int().min(0).max(100).optional().describe("Volume of the attached music track, 0-100 (default 100). Only used with audio_id."),
710
762
  video_volume: z.number().int().min(0).max(100).optional().describe("Volume of the video's own audio, 0-100 (default 100). Set 0 for a music-only Reel. Only used with audio_id."),
@@ -732,22 +784,22 @@ Do NOT call without required media — it will fail.`, {
732
784
  made_with_ai: z.boolean().optional(),
733
785
  thread_parts: z.array(z.object({
734
786
  text: z.string().describe("Tweet text (≤ 280 chars). X counts every link as 23 characters (its t.co length), so a short link still uses 23 toward the limit."),
735
- media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
736
- media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
787
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Each entry is a plain ID string or { id, alt } to attach alt text (X applies it to photos/GIFs). Attach your uploaded graphics to any tweet in the thread."),
788
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids). Each entry is a plain URL string or { url, alt } to attach alt text (X applies it to photos/GIFs)."),
737
789
  })).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
738
790
  }).optional().describe("X (Twitter) options"),
739
791
  bluesky: z.object({
740
792
  thread_parts: z.array(z.object({
741
793
  text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
742
- media_ids: z.array(z.string()).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images."),
743
- media_urls: z.array(z.string()).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images."),
794
+ media_ids: z.array(mediaIdEntry).max(4).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 4 images. Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
795
+ media_urls: z.array(mediaUrlEntry).max(4).optional().describe("Per-post media as external URLs. A part is one video OR up to 4 images. Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
744
796
  })).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to 4 images per part), or `null` to revert to single-post mode."),
745
797
  }).optional().describe("Bluesky options"),
746
798
  mastodon: z.object({
747
799
  thread_parts: z.array(z.object({
748
800
  text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
749
- media_ids: z.array(z.string()).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4)."),
750
- media_urls: z.array(z.string()).max(4).optional().describe("Per-status media as external URLs (max 4)."),
801
+ 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)."),
802
+ 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)."),
751
803
  })).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."),
752
804
  }).optional().describe("Mastodon options"),
753
805
  google_business: z.object({
package/build/types.d.ts CHANGED
@@ -80,7 +80,12 @@ export interface Post {
80
80
  thread_parts?: Array<{
81
81
  id?: string;
82
82
  text: string;
83
- media_urls?: string[];
83
+ /** Entries are plain URLs, or { url, alt } when the media carries an
84
+ * accessibility description (alt text). */
85
+ media_urls?: Array<string | {
86
+ url: string;
87
+ alt?: string;
88
+ }>;
84
89
  }>;
85
90
  [key: string]: unknown;
86
91
  };
@@ -154,6 +159,17 @@ export interface Webhook {
154
159
  } | null;
155
160
  created_at: string;
156
161
  }
162
+ export interface HashtagSet {
163
+ id: string;
164
+ name: string;
165
+ /** Ordered tags WITHOUT the leading '#' */
166
+ hashtags: string[];
167
+ hashtag_count: number;
168
+ /** The tags rendered as caption text, e.g. "#fitness #gym" */
169
+ preview: string;
170
+ created_at: string;
171
+ updated_at: string;
172
+ }
157
173
  export interface AnalyticsOverview {
158
174
  total_posts: number;
159
175
  total_impressions: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.14.0",
3
+ "version": "1.17.0",
4
4
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
5
5
  "type": "module",
6
6
  "main": "build/index.js",