@omnisocials/mcp-server 1.15.0 → 1.18.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 +27 -0
- package/build/client.d.ts +57 -18
- package/build/client.js +18 -0
- package/build/index.js +3 -1
- package/build/tools/hashtag-sets.d.ts +3 -0
- package/build/tools/hashtag-sets.js +78 -0
- package/build/tools/media.js +1 -1
- package/build/tools/posts.js +128 -45
- package/build/types.d.ts +17 -1
- package/package.json +1 -1
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,22 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
207
218
|
|
|
208
219
|
## Changelog
|
|
209
220
|
|
|
221
|
+
### 1.18.0
|
|
222
|
+
|
|
223
|
+
- **`retry_post`:** retry the failed platforms of a `failed` or partially failed (`warning`) post on the same post — only failed platforms are re-published, succeeded ones never post twice. Async via the publishing queue; poll `get_post` for the outcome. Max 3 retries per platform. Backed by `POST /api/v1/posts/{id}/retry`.
|
|
224
|
+
- **Retry linkage:** posts expose `retry_of` / `retries`, and `get_post` renders them — a `published` post with empty `published_urls` and `retries` set is a resolved failure, not a second publish.
|
|
225
|
+
- **`get_post` now renders per-platform publish errors** (previously dropped) with a pointer to `retry_post`.
|
|
226
|
+
|
|
227
|
+
### 1.17.0
|
|
228
|
+
|
|
229
|
+
- **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.
|
|
230
|
+
|
|
231
|
+
### 1.16.0
|
|
232
|
+
|
|
233
|
+
- **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`.
|
|
234
|
+
- `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`.
|
|
235
|
+
- Same tools on the companion server (`mcp.omnisocials.com`).
|
|
236
|
+
|
|
210
237
|
### 1.15.0
|
|
211
238
|
|
|
212
239
|
- **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.
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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?:
|
|
101
|
-
media_urls?:
|
|
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?:
|
|
132
|
-
media_urls?:
|
|
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?:
|
|
164
|
-
media_urls?:
|
|
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[];
|
|
@@ -185,6 +213,7 @@ export declare class OmniSocialsClient {
|
|
|
185
213
|
}): Promise<ApiResponse<unknown>>;
|
|
186
214
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
|
187
215
|
publishPost(id: string): Promise<ApiResponse<unknown>>;
|
|
216
|
+
retryPost(id: string): Promise<ApiResponse<unknown>>;
|
|
188
217
|
searchLocations(query: string): Promise<ApiResponse<unknown>>;
|
|
189
218
|
validateLocation(id: string): Promise<ApiResponse<unknown>>;
|
|
190
219
|
searchInstagramAudio(query?: string, type?: "music" | "original_sound"): Promise<ApiResponse<unknown>>;
|
|
@@ -234,6 +263,16 @@ export declare class OmniSocialsClient {
|
|
|
234
263
|
name: string;
|
|
235
264
|
parent_id?: string;
|
|
236
265
|
}): Promise<ApiResponse<unknown>>;
|
|
266
|
+
listHashtagSets(): Promise<ApiResponse<unknown>>;
|
|
267
|
+
createHashtagSet(data: {
|
|
268
|
+
name: string;
|
|
269
|
+
hashtags: string[];
|
|
270
|
+
}): Promise<ApiResponse<unknown>>;
|
|
271
|
+
updateHashtagSet(id: string, data: {
|
|
272
|
+
name?: string;
|
|
273
|
+
hashtags?: string[];
|
|
274
|
+
}): Promise<ApiResponse<unknown>>;
|
|
275
|
+
deleteHashtagSet(id: string): Promise<ApiResponse<unknown>>;
|
|
237
276
|
listAccounts(): Promise<ApiResponse<unknown>>;
|
|
238
277
|
getAccount(id: string): Promise<ApiResponse<unknown>>;
|
|
239
278
|
getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
|
package/build/client.js
CHANGED
|
@@ -219,6 +219,11 @@ export class OmniSocialsClient {
|
|
|
219
219
|
async publishPost(id) {
|
|
220
220
|
return this.request("POST", `/posts/${id}/publish`);
|
|
221
221
|
}
|
|
222
|
+
// Retry the failed platforms of a failed/warning post on the same post.
|
|
223
|
+
// Succeeded platforms are never re-published (server-side idempotency guard).
|
|
224
|
+
async retryPost(id) {
|
|
225
|
+
return this.request("POST", `/posts/${id}/retry`);
|
|
226
|
+
}
|
|
222
227
|
// Locations (Instagram place tagging)
|
|
223
228
|
async searchLocations(query) {
|
|
224
229
|
return this.request("GET", "/locations/search", undefined, { q: query });
|
|
@@ -272,6 +277,19 @@ export class OmniSocialsClient {
|
|
|
272
277
|
async createFolder(data) {
|
|
273
278
|
return this.request("POST", "/folders", data);
|
|
274
279
|
}
|
|
280
|
+
// Hashtag Sets
|
|
281
|
+
async listHashtagSets() {
|
|
282
|
+
return this.request("GET", "/hashtag-sets");
|
|
283
|
+
}
|
|
284
|
+
async createHashtagSet(data) {
|
|
285
|
+
return this.request("POST", "/hashtag-sets", data);
|
|
286
|
+
}
|
|
287
|
+
async updateHashtagSet(id, data) {
|
|
288
|
+
return this.request("PATCH", `/hashtag-sets/${id}`, data);
|
|
289
|
+
}
|
|
290
|
+
async deleteHashtagSet(id) {
|
|
291
|
+
return this.request("DELETE", `/hashtag-sets/${id}`);
|
|
292
|
+
}
|
|
275
293
|
// Accounts
|
|
276
294
|
async listAccounts() {
|
|
277
295
|
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.
|
|
32
|
+
version: "1.18.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,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
|
+
}
|
package/build/tools/media.js
CHANGED
|
@@ -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
|
};
|
package/build/tools/posts.js
CHANGED
|
@@ -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/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.", {
|
|
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);
|
|
@@ -171,6 +193,14 @@ export function registerPostTools(server, getClient) {
|
|
|
171
193
|
md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
|
|
172
194
|
if (appUrl)
|
|
173
195
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
196
|
+
// Retry linkage: a "published" post with `retries` set and empty
|
|
197
|
+
// published_urls is a resolved failure — the live URLs are on the retry
|
|
198
|
+
// post, so don't count both as separate publishes.
|
|
199
|
+
if (p.retry_of)
|
|
200
|
+
md += `| **Retry of post** | \`${p.retry_of}\` (this post is a retry of that failed post) |\n`;
|
|
201
|
+
if (Array.isArray(p.retries) && p.retries.length) {
|
|
202
|
+
md += `| **Retried by** | ${p.retries.map((r) => `\`${r}\``).join(", ")} (live URLs are on the retry post) |\n`;
|
|
203
|
+
}
|
|
174
204
|
// Media can be a flat array (same set everywhere) or a per-platform
|
|
175
205
|
// object ({ default: [...], instagram: [...] }). The old `p.media.length`
|
|
176
206
|
// check silently dropped the object shape (`.length` is undefined).
|
|
@@ -204,7 +234,8 @@ export function registerPostTools(server, getClient) {
|
|
|
204
234
|
md += `**${group === "default" ? "Default (all platforms)" : capitalize(group.replace(/_/g, " "))}**\n`;
|
|
205
235
|
}
|
|
206
236
|
items.forEach((m, i) => {
|
|
207
|
-
|
|
237
|
+
const alt = typeof m?.alt === "string" && m.alt ? ` — alt: "${m.alt}"` : "";
|
|
238
|
+
md += `${i + 1}. ${mediaUrlOf(m) || "*(no url)*"}${alt}\n`;
|
|
208
239
|
});
|
|
209
240
|
md += `\n`;
|
|
210
241
|
}
|
|
@@ -221,8 +252,10 @@ export function registerPostTools(server, getClient) {
|
|
|
221
252
|
const text = typeof part.text === "string" ? part.text : "";
|
|
222
253
|
md += `**${i + 1}/${threadParts.length}.** ${text}\n`;
|
|
223
254
|
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
224
|
-
for (const
|
|
225
|
-
|
|
255
|
+
for (const entry of part.media_urls) {
|
|
256
|
+
const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
|
|
257
|
+
md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
|
|
258
|
+
}
|
|
226
259
|
}
|
|
227
260
|
md += `\n`;
|
|
228
261
|
}
|
|
@@ -238,8 +271,10 @@ export function registerPostTools(server, getClient) {
|
|
|
238
271
|
const text = typeof part.text === "string" ? part.text : "";
|
|
239
272
|
md += `**${i + 1}/${bskyThreadParts.length}.** ${text}\n`;
|
|
240
273
|
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
241
|
-
for (const
|
|
242
|
-
|
|
274
|
+
for (const entry of part.media_urls) {
|
|
275
|
+
const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
|
|
276
|
+
md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
|
|
277
|
+
}
|
|
243
278
|
}
|
|
244
279
|
md += `\n`;
|
|
245
280
|
}
|
|
@@ -255,8 +290,10 @@ export function registerPostTools(server, getClient) {
|
|
|
255
290
|
const text = typeof part.text === "string" ? part.text : "";
|
|
256
291
|
md += `**${i + 1}/${mastoThreadParts.length}.** ${text}\n`;
|
|
257
292
|
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
258
|
-
for (const
|
|
259
|
-
|
|
293
|
+
for (const entry of part.media_urls) {
|
|
294
|
+
const alt = typeof entry === "object" && entry?.alt ? ` — alt: "${entry.alt}"` : "";
|
|
295
|
+
md += ` - ${mediaUrlOf(entry) || "*(no url)*"}${alt}\n`;
|
|
296
|
+
}
|
|
260
297
|
}
|
|
261
298
|
md += `\n`;
|
|
262
299
|
}
|
|
@@ -271,6 +308,21 @@ export function registerPostTools(server, getClient) {
|
|
|
271
308
|
md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
|
|
272
309
|
}
|
|
273
310
|
}
|
|
311
|
+
// Per-platform publish errors (status failed/warning). Point at
|
|
312
|
+
// retry_post — only the failed platforms are re-published.
|
|
313
|
+
const postErrors = (p.errors && typeof p.errors === "object" && !Array.isArray(p.errors))
|
|
314
|
+
? p.errors
|
|
315
|
+
: {};
|
|
316
|
+
const errorEntries = Object.entries(postErrors);
|
|
317
|
+
if (errorEntries.length) {
|
|
318
|
+
md += `\n\n### Publish Errors\n\n`;
|
|
319
|
+
for (const [platform, message] of errorEntries) {
|
|
320
|
+
md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${message}\n`;
|
|
321
|
+
}
|
|
322
|
+
if (p.status === "failed" || p.status === "warning") {
|
|
323
|
+
md += `\n_Retry the failed platform(s) with \`retry_post\` — platforms that already succeeded are never re-published._\n`;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
274
326
|
// First comments: nested under each platform object by the API. Show the
|
|
275
327
|
// configured text plus, once published, the outcome (posted/failed/skipped).
|
|
276
328
|
const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube"];
|
|
@@ -367,13 +419,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
367
419
|
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."),
|
|
368
420
|
scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing"),
|
|
369
421
|
media_ids: z.union([
|
|
370
|
-
z.array(
|
|
371
|
-
z.record(z.string(), z.array(
|
|
372
|
-
]).optional().describe("Media IDs from upload — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...] }"),
|
|
422
|
+
z.array(mediaIdEntry),
|
|
423
|
+
z.record(z.string(), z.array(mediaIdEntry)),
|
|
424
|
+
]).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."),
|
|
373
425
|
media_urls: z.union([
|
|
374
|
-
z.array(
|
|
375
|
-
z.record(z.string(), z.array(
|
|
376
|
-
]).optional().describe("External image/video URLs — flat array (same for all platforms) or object with platform keys: { default: [...], instagram: [...], 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`."),
|
|
426
|
+
z.array(mediaUrlEntry),
|
|
427
|
+
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
428
|
+
]).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`."),
|
|
377
429
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story' (Instagram/Facebook/Snapchat), 'reel' (Instagram/Facebook/YouTube/TikTok)"),
|
|
378
430
|
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)."),
|
|
379
431
|
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."),
|
|
@@ -387,6 +439,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
387
439
|
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
388
440
|
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
389
441
|
})).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."),
|
|
442
|
+
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."),
|
|
443
|
+
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."),
|
|
444
|
+
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."),
|
|
390
445
|
pinterest: z.object({
|
|
391
446
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
392
447
|
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."),
|
|
@@ -436,22 +491,22 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
436
491
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
437
492
|
thread_parts: z.array(z.object({
|
|
438
493
|
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."),
|
|
439
|
-
media_ids: z.array(
|
|
440
|
-
media_urls: z.array(
|
|
494
|
+
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."),
|
|
495
|
+
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)."),
|
|
441
496
|
})).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."),
|
|
442
497
|
}).optional().describe("X (Twitter) options"),
|
|
443
498
|
bluesky: z.object({
|
|
444
499
|
thread_parts: z.array(z.object({
|
|
445
500
|
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
446
|
-
media_ids: z.array(
|
|
447
|
-
media_urls: z.array(
|
|
501
|
+
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)."),
|
|
502
|
+
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)."),
|
|
448
503
|
})).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."),
|
|
449
504
|
}).optional().describe("Bluesky options"),
|
|
450
505
|
mastodon: z.object({
|
|
451
506
|
thread_parts: z.array(z.object({
|
|
452
507
|
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
453
|
-
media_ids: z.array(
|
|
454
|
-
media_urls: z.array(
|
|
508
|
+
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)."),
|
|
509
|
+
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)."),
|
|
455
510
|
})).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."),
|
|
456
511
|
}).optional().describe("Mastodon options"),
|
|
457
512
|
google_business: z.object({
|
|
@@ -545,13 +600,13 @@ Do NOT call without required media — it will fail.`, {
|
|
|
545
600
|
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."),
|
|
546
601
|
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."),
|
|
547
602
|
media_ids: z.union([
|
|
548
|
-
z.array(
|
|
549
|
-
z.record(z.string(), z.array(
|
|
550
|
-
]).optional().describe("Media IDs from upload — flat array or per-platform object"),
|
|
603
|
+
z.array(mediaIdEntry),
|
|
604
|
+
z.record(z.string(), z.array(mediaIdEntry)),
|
|
605
|
+
]).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."),
|
|
551
606
|
media_urls: z.union([
|
|
552
|
-
z.array(
|
|
553
|
-
z.record(z.string(), z.array(
|
|
554
|
-
]).optional().describe("External image/video URLs — flat array or per-platform object. 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."),
|
|
607
|
+
z.array(mediaUrlEntry),
|
|
608
|
+
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
609
|
+
]).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."),
|
|
555
610
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
|
|
556
611
|
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."),
|
|
557
612
|
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."),
|
|
@@ -561,6 +616,9 @@ Do NOT call without required media — it will fail.`, {
|
|
|
561
616
|
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
562
617
|
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
563
618
|
})).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."),
|
|
619
|
+
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."),
|
|
620
|
+
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)."),
|
|
621
|
+
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."),
|
|
564
622
|
pinterest: z.object({
|
|
565
623
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
566
624
|
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."),
|
|
@@ -605,22 +663,22 @@ Do NOT call without required media — it will fail.`, {
|
|
|
605
663
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
606
664
|
thread_parts: z.array(z.object({
|
|
607
665
|
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."),
|
|
608
|
-
media_ids: z.array(
|
|
609
|
-
media_urls: z.array(
|
|
666
|
+
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."),
|
|
667
|
+
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)."),
|
|
610
668
|
})).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."),
|
|
611
669
|
}).optional().describe("X (Twitter) options"),
|
|
612
670
|
bluesky: z.object({
|
|
613
671
|
thread_parts: z.array(z.object({
|
|
614
672
|
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
615
|
-
media_ids: z.array(
|
|
616
|
-
media_urls: z.array(
|
|
673
|
+
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)."),
|
|
674
|
+
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)."),
|
|
617
675
|
})).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."),
|
|
618
676
|
}).optional().describe("Bluesky options"),
|
|
619
677
|
mastodon: z.object({
|
|
620
678
|
thread_parts: z.array(z.object({
|
|
621
679
|
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
622
|
-
media_ids: z.array(
|
|
623
|
-
media_urls: z.array(
|
|
680
|
+
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)."),
|
|
681
|
+
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)."),
|
|
624
682
|
})).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."),
|
|
625
683
|
}).optional().describe("Mastodon options"),
|
|
626
684
|
google_business: z.object({
|
|
@@ -685,13 +743,13 @@ Do NOT call without required media — it will fail.`, {
|
|
|
685
743
|
scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
|
|
686
744
|
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."),
|
|
687
745
|
media_ids: z.union([
|
|
688
|
-
z.array(
|
|
689
|
-
z.record(z.string(), z.array(
|
|
690
|
-
]).optional().describe("Media IDs — flat array or per-platform object"),
|
|
746
|
+
z.array(mediaIdEntry),
|
|
747
|
+
z.record(z.string(), z.array(mediaIdEntry)),
|
|
748
|
+
]).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."),
|
|
691
749
|
media_urls: z.union([
|
|
692
|
-
z.array(
|
|
693
|
-
z.record(z.string(), z.array(
|
|
694
|
-
]).optional().describe("External URLs — flat array or per-platform object. 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."),
|
|
750
|
+
z.array(mediaUrlEntry),
|
|
751
|
+
z.record(z.string(), z.array(mediaUrlEntry)),
|
|
752
|
+
]).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."),
|
|
695
753
|
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."),
|
|
696
754
|
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."),
|
|
697
755
|
user_tags: z.array(z.object({
|
|
@@ -749,22 +807,22 @@ Do NOT call without required media — it will fail.`, {
|
|
|
749
807
|
made_with_ai: z.boolean().optional(),
|
|
750
808
|
thread_parts: z.array(z.object({
|
|
751
809
|
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."),
|
|
752
|
-
media_ids: z.array(
|
|
753
|
-
media_urls: z.array(
|
|
810
|
+
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."),
|
|
811
|
+
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)."),
|
|
754
812
|
})).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."),
|
|
755
813
|
}).optional().describe("X (Twitter) options"),
|
|
756
814
|
bluesky: z.object({
|
|
757
815
|
thread_parts: z.array(z.object({
|
|
758
816
|
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
759
|
-
media_ids: z.array(
|
|
760
|
-
media_urls: z.array(
|
|
817
|
+
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)."),
|
|
818
|
+
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)."),
|
|
761
819
|
})).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."),
|
|
762
820
|
}).optional().describe("Bluesky options"),
|
|
763
821
|
mastodon: z.object({
|
|
764
822
|
thread_parts: z.array(z.object({
|
|
765
823
|
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
766
|
-
media_ids: z.array(
|
|
767
|
-
media_urls: z.array(
|
|
824
|
+
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)."),
|
|
825
|
+
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)."),
|
|
768
826
|
})).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."),
|
|
769
827
|
}).optional().describe("Mastodon options"),
|
|
770
828
|
google_business: z.object({
|
|
@@ -819,7 +877,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
819
877
|
content: [{ type: "text", text: result.error ? `Error (${result.error.code}): ${result.error.message}` : "Post deleted successfully." }],
|
|
820
878
|
};
|
|
821
879
|
});
|
|
822
|
-
server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing.
|
|
880
|
+
server.tool("publish_post", `Publish a draft or scheduled post immediately. The post will be queued for publishing. Only draft and scheduled posts can be published — for a failed or partially failed (warning) post use \`retry_post\` instead, which re-publishes only the failed platforms.
|
|
823
881
|
|
|
824
882
|
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.`, {
|
|
825
883
|
id: z.string().describe("The post ID to publish"),
|
|
@@ -840,6 +898,31 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
840
898
|
content: [{ type: "text", text: md }],
|
|
841
899
|
};
|
|
842
900
|
});
|
|
901
|
+
server.tool("retry_post", `Retry the failed platforms of a failed or partially failed post — on the same post, no duplicate created. Use when a post has status \`failed\` (every platform failed) or \`warning\` (some failed, some published): only the FAILED platforms are re-published; platforms that already succeeded are never posted again.
|
|
902
|
+
|
|
903
|
+
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.`, {
|
|
904
|
+
id: z.string().describe("The failed or partially failed post ID to retry"),
|
|
905
|
+
}, async ({ id }) => {
|
|
906
|
+
const result = await getClient().retryPost(id);
|
|
907
|
+
if (result.error) {
|
|
908
|
+
return {
|
|
909
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
const p = result.data;
|
|
913
|
+
let md = `## Retry Queued\n\n`;
|
|
914
|
+
md += `| Field | Value |\n`;
|
|
915
|
+
md += `|-------|-------|\n`;
|
|
916
|
+
md += `| **ID** | \`${p.id}\` |\n`;
|
|
917
|
+
md += `| **Status** | ${capitalize(p.status || "posting")} |\n`;
|
|
918
|
+
if (Array.isArray(p.platforms) && p.platforms.length) {
|
|
919
|
+
md += `| **Retrying** | ${p.platforms.map((x) => capitalize(x.replace(/_/g, " "))).join(", ")} |\n`;
|
|
920
|
+
}
|
|
921
|
+
md += `\n_Platforms that already succeeded are never re-published. Check \`get_post\` in a minute or two for the outcome._`;
|
|
922
|
+
return {
|
|
923
|
+
content: [{ type: "text", text: md }],
|
|
924
|
+
};
|
|
925
|
+
});
|
|
843
926
|
server.tool("search_locations", `Search for an Instagram location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
|
|
844
927
|
|
|
845
928
|
Flow: call this with the place name → present the options to the user → once they pick, pass that result's \`id\` as \`location_id\` on create_post / create_and_publish_post / update_post. Only Instagram supports location tagging.
|
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
|
-
|
|
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