@omnisocials/mcp-server 1.3.0 → 1.3.2
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/build/client.d.ts +25 -4
- package/build/client.js +23 -3
- package/build/tools/media.js +9 -2
- package/build/tools/posts.js +170 -42
- package/build/tools/workspaces.js +2 -2
- package/build/types.d.ts +11 -0
- package/package.json +1 -1
package/build/client.d.ts
CHANGED
|
@@ -7,8 +7,29 @@ export declare function formatNumber(n: number): string;
|
|
|
7
7
|
export declare function formatBytes(bytes: number): string;
|
|
8
8
|
export declare function formatDate(iso: string | null): string;
|
|
9
9
|
export declare function formatDateTime(iso: string | null): string;
|
|
10
|
-
export declare function truncate(
|
|
10
|
+
export declare function truncate(value: unknown, len: number): string;
|
|
11
11
|
export declare function capitalize(str: string): string;
|
|
12
|
+
export interface XThreadPartInput {
|
|
13
|
+
/** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
|
|
14
|
+
text: string;
|
|
15
|
+
/** Optional per-part media URLs (max 4). Overrides top-level media_urls.x. */
|
|
16
|
+
media_urls?: string[];
|
|
17
|
+
}
|
|
18
|
+
export interface XPostOptions {
|
|
19
|
+
reply_settings?: "" | "following" | "mentionedUsers";
|
|
20
|
+
paid_partnership?: boolean;
|
|
21
|
+
made_with_ai?: boolean;
|
|
22
|
+
/** Provide 2–25 parts to publish as a thread. Omit for a single tweet. */
|
|
23
|
+
thread_parts?: XThreadPartInput[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Update-side variant: passing `thread_parts: null` explicitly clears the
|
|
27
|
+
* thread shape on the post (reverting to single-tweet mode). Passing
|
|
28
|
+
* `undefined` (omit it) leaves the existing thread untouched.
|
|
29
|
+
*/
|
|
30
|
+
export interface XPostOptionsUpdate extends Omit<XPostOptions, "thread_parts"> {
|
|
31
|
+
thread_parts?: XThreadPartInput[] | null;
|
|
32
|
+
}
|
|
12
33
|
export declare class OmniSocialsClient {
|
|
13
34
|
private baseUrl;
|
|
14
35
|
private apiKey;
|
|
@@ -32,7 +53,7 @@ export declare class OmniSocialsClient {
|
|
|
32
53
|
youtube?: Record<string, unknown>;
|
|
33
54
|
instagram?: Record<string, unknown>;
|
|
34
55
|
tiktok?: Record<string, unknown>;
|
|
35
|
-
x?:
|
|
56
|
+
x?: XPostOptions;
|
|
36
57
|
}): Promise<ApiResponse<unknown>>;
|
|
37
58
|
createAndPublishPost(data: {
|
|
38
59
|
content: string | Record<string, string>;
|
|
@@ -45,7 +66,7 @@ export declare class OmniSocialsClient {
|
|
|
45
66
|
youtube?: Record<string, unknown>;
|
|
46
67
|
instagram?: Record<string, unknown>;
|
|
47
68
|
tiktok?: Record<string, unknown>;
|
|
48
|
-
x?:
|
|
69
|
+
x?: XPostOptions;
|
|
49
70
|
}): Promise<ApiResponse<unknown>>;
|
|
50
71
|
updatePost(id: string, data: {
|
|
51
72
|
content?: string | Record<string, string>;
|
|
@@ -58,7 +79,7 @@ export declare class OmniSocialsClient {
|
|
|
58
79
|
youtube?: Record<string, unknown>;
|
|
59
80
|
instagram?: Record<string, unknown>;
|
|
60
81
|
tiktok?: Record<string, unknown>;
|
|
61
|
-
x?:
|
|
82
|
+
x?: XPostOptionsUpdate;
|
|
62
83
|
}): Promise<ApiResponse<unknown>>;
|
|
63
84
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
|
64
85
|
publishPost(id: string): Promise<ApiResponse<unknown>>;
|
package/build/client.js
CHANGED
|
@@ -51,16 +51,36 @@ export function formatDateTime(iso) {
|
|
|
51
51
|
if (!iso)
|
|
52
52
|
return "—";
|
|
53
53
|
const d = new Date(iso);
|
|
54
|
+
// Always render in UTC with an explicit "UTC" suffix so the timezone is
|
|
55
|
+
// unambiguous regardless of which Node runtime / ICU build the MCP runs in.
|
|
56
|
+
// Agents can convert to the user's local time when answering.
|
|
54
57
|
return d.toLocaleDateString("en-US", {
|
|
55
58
|
month: "short",
|
|
56
59
|
day: "numeric",
|
|
57
60
|
year: "numeric",
|
|
58
61
|
hour: "numeric",
|
|
59
62
|
minute: "2-digit",
|
|
60
|
-
|
|
61
|
-
});
|
|
63
|
+
timeZone: "UTC",
|
|
64
|
+
}) + " UTC";
|
|
62
65
|
}
|
|
63
|
-
|
|
66
|
+
// Render any caption-like value (string or per-platform object) to a single
|
|
67
|
+
// display string, then truncate. The backend returns `content` as an object
|
|
68
|
+
// with one key per platform plus `default`; older callers may pass a string.
|
|
69
|
+
export function truncate(value, len) {
|
|
70
|
+
let str;
|
|
71
|
+
if (typeof value === "string") {
|
|
72
|
+
str = value;
|
|
73
|
+
}
|
|
74
|
+
else if (value && typeof value === "object") {
|
|
75
|
+
const obj = value;
|
|
76
|
+
const first = (typeof obj.default === "string" && obj.default) ||
|
|
77
|
+
Object.values(obj).find((v) => typeof v === "string" && v.length > 0) ||
|
|
78
|
+
"";
|
|
79
|
+
str = first;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
64
84
|
if (!str)
|
|
65
85
|
return "";
|
|
66
86
|
const clean = str.replace(/\n/g, " ");
|
package/build/tools/media.js
CHANGED
|
@@ -22,13 +22,20 @@ export function registerMediaTools(server, getClient) {
|
|
|
22
22
|
md += `|---|------|----------|------|----------|\n`;
|
|
23
23
|
for (let i = 0; i < items.length; i++) {
|
|
24
24
|
const m = items[i];
|
|
25
|
-
|
|
25
|
+
// `type` may be "video"/"image" (normalized) or a raw MIME like "video/mp4".
|
|
26
|
+
const rawType = typeof m.type === "string" ? m.type : "";
|
|
27
|
+
const isVideo = rawType === "video" || rawType.startsWith("video/");
|
|
28
|
+
const type = isVideo ? "Video" : "Image";
|
|
26
29
|
md += `| ${i + 1} | ${type} | ${m.filename || "—"} | ${formatBytes(m.size || 0)} | \`${m.id}\` |\n`;
|
|
27
30
|
}
|
|
28
31
|
md += `\nUse the **Media ID** with \`media_ids\` when creating posts.`;
|
|
29
32
|
// Fetch thumbnails for image files (max 4)
|
|
30
33
|
const content = [];
|
|
31
|
-
const imageItems = items.filter((m) =>
|
|
34
|
+
const imageItems = items.filter((m) => {
|
|
35
|
+
const t = typeof m.type === "string" ? m.type : "";
|
|
36
|
+
const isVideo = t === "video" || t.startsWith("video/");
|
|
37
|
+
return !isVideo && m.url;
|
|
38
|
+
}).slice(0, 4);
|
|
32
39
|
const imagePromises = imageItems.map(async (m) => {
|
|
33
40
|
const img = await fetchImageAsBase64(m.url);
|
|
34
41
|
return img ? { ...img, id: m.id } : null;
|
package/build/tools/posts.js
CHANGED
|
@@ -1,7 +1,46 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatDateTime, truncate, capitalize } from "../client.js";
|
|
3
|
+
// Render full content for get_post — preserves per-platform overrides so Claude
|
|
4
|
+
// can see custom captions (e.g. a shorter X version) instead of only `default`.
|
|
5
|
+
function renderContent(content) {
|
|
6
|
+
if (!content)
|
|
7
|
+
return "(empty)";
|
|
8
|
+
if (typeof content === "string")
|
|
9
|
+
return content;
|
|
10
|
+
if (typeof content !== "object")
|
|
11
|
+
return "(empty)";
|
|
12
|
+
const entries = Object.entries(content).filter(([, v]) => typeof v === "string" && v);
|
|
13
|
+
if (!entries.length)
|
|
14
|
+
return "(empty)";
|
|
15
|
+
if (entries.length === 1)
|
|
16
|
+
return entries[0][1];
|
|
17
|
+
return entries
|
|
18
|
+
.map(([key, value]) => `**${key === "default" ? "Default (fallback)" : capitalize(key.replace(/_/g, " "))}**\n\n${value}`)
|
|
19
|
+
.join("\n\n---\n\n");
|
|
20
|
+
}
|
|
21
|
+
// The API returns `accounts` as either an array of platform IDs or an object
|
|
22
|
+
// map `{platform: true|false}`. Older clients may still receive `channels`.
|
|
23
|
+
// Normalize to an array of selected platform names.
|
|
24
|
+
function selectedChannels(p) {
|
|
25
|
+
const src = p?.accounts ?? p?.channels;
|
|
26
|
+
if (!src)
|
|
27
|
+
return [];
|
|
28
|
+
if (Array.isArray(src))
|
|
29
|
+
return src.filter((x) => typeof x === "string" && !!x);
|
|
30
|
+
if (typeof src === "object") {
|
|
31
|
+
return Object.entries(src)
|
|
32
|
+
.filter(([, v]) => !!v)
|
|
33
|
+
.map(([k]) => k);
|
|
34
|
+
}
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
// Pick the most informative timestamp to display. API returns `schedule_at`;
|
|
38
|
+
// `scheduled_at` is kept as a defensive fallback for older response shapes.
|
|
39
|
+
function displayDate(p) {
|
|
40
|
+
return p?.schedule_at ?? p?.scheduled_at ?? p?.published_at ?? p?.created_at ?? null;
|
|
41
|
+
}
|
|
3
42
|
export function registerPostTools(server, getClient) {
|
|
4
|
-
server.tool("list_posts", "List all posts in the workspace. Optionally filter by status.", {
|
|
43
|
+
server.tool("list_posts", "List all posts in the workspace. Optionally filter by status. The content column shows a preview of the default caption; if a post has per-platform overrides (e.g. a custom X version), the preview is suffixed with *(per-platform)* — call `get_post` to see every variant.", {
|
|
5
44
|
status: z.string().optional().describe("Filter by status: draft, scheduled, published, failed"),
|
|
6
45
|
limit: z.string().optional().describe("Max results to return (default: 20)"),
|
|
7
46
|
offset: z.string().optional().describe("Offset for pagination"),
|
|
@@ -24,24 +63,31 @@ export function registerPostTools(server, getClient) {
|
|
|
24
63
|
md += `|---|---------|----------|--------|------|----|\n`;
|
|
25
64
|
for (let i = 0; i < posts.length; i++) {
|
|
26
65
|
const p = posts[i];
|
|
27
|
-
const
|
|
66
|
+
const isObj = typeof p.content === "object" && p.content !== null;
|
|
67
|
+
const rawContent = isObj
|
|
28
68
|
? (p.content.default || Object.values(p.content).find((v) => typeof v === "string" && v) || "")
|
|
29
69
|
: (p.content || "");
|
|
30
|
-
const
|
|
31
|
-
const
|
|
70
|
+
const hasOverrides = isObj && Object.keys(p.content).filter((k) => k !== "default" && typeof p.content[k] === "string" && p.content[k]).length > 0;
|
|
71
|
+
const threadParts = Array.isArray(p.x?.thread_parts) ? p.x.thread_parts : [];
|
|
72
|
+
const isThread = threadParts.length >= 2;
|
|
73
|
+
let content;
|
|
74
|
+
if (isThread && !rawContent) {
|
|
75
|
+
const first = typeof threadParts[0]?.text === "string" ? threadParts[0].text : "";
|
|
76
|
+
content = truncate(first, 32) + ` *(X thread, ${threadParts.length} parts)*`;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
content = truncate(rawContent, hasOverrides ? 35 : 50) + (hasOverrides ? " *(per-platform)*" : "");
|
|
80
|
+
}
|
|
81
|
+
const channels = selectedChannels(p).join(", ") || "—";
|
|
32
82
|
const status = capitalize(p.status || "—");
|
|
33
|
-
const date = p
|
|
34
|
-
? formatDateTime(p.scheduled_at)
|
|
35
|
-
: p.published_at
|
|
36
|
-
? formatDateTime(p.published_at)
|
|
37
|
-
: formatDateTime(p.created_at);
|
|
83
|
+
const date = formatDateTime(displayDate(p));
|
|
38
84
|
md += `| ${i + 1} | ${content} | ${channels} | ${status} | ${date} | \`${p.id}\` |\n`;
|
|
39
85
|
}
|
|
40
86
|
return {
|
|
41
87
|
content: [{ type: "text", text: md }],
|
|
42
88
|
};
|
|
43
89
|
});
|
|
44
|
-
server.tool("get_post", "Get details of a specific post by ID.", {
|
|
90
|
+
server.tool("get_post", "Get details of a specific post by ID. 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.", {
|
|
45
91
|
id: z.string().describe("The post ID"),
|
|
46
92
|
}, async ({ id }) => {
|
|
47
93
|
const result = await getClient().getPost(id);
|
|
@@ -59,19 +105,47 @@ export function registerPostTools(server, getClient) {
|
|
|
59
105
|
let md = `## Post Details\n\n`;
|
|
60
106
|
md += `| Field | Value |\n`;
|
|
61
107
|
md += `|-------|-------|\n`;
|
|
108
|
+
const schedAt = p.schedule_at ?? p.scheduled_at;
|
|
62
109
|
md += `| **ID** | \`${p.id}\` |\n`;
|
|
63
110
|
md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
|
|
64
111
|
md += `| **Type** | ${p.type || "post"} |\n`;
|
|
65
|
-
md += `| **Channels** | ${(p
|
|
66
|
-
if (
|
|
67
|
-
md += `| **Scheduled** | ${formatDateTime(
|
|
112
|
+
md += `| **Channels** | ${selectedChannels(p).join(", ") || "—"} |\n`;
|
|
113
|
+
if (schedAt)
|
|
114
|
+
md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
|
|
68
115
|
if (p.published_at)
|
|
69
116
|
md += `| **Published** | ${formatDateTime(p.published_at)} |\n`;
|
|
70
117
|
md += `| **Created** | ${formatDateTime(p.created_at)} |\n`;
|
|
71
118
|
if (p.media?.length) {
|
|
72
119
|
md += `| **Media** | ${p.media.length} file(s) |\n`;
|
|
73
120
|
}
|
|
74
|
-
md += `\n### Content\n\n${p.content
|
|
121
|
+
md += `\n### Content\n\n${renderContent(p.content)}`;
|
|
122
|
+
// X thread: when present, the canonical tweet text lives here, not in `content`.
|
|
123
|
+
const threadParts = Array.isArray(p.x?.thread_parts)
|
|
124
|
+
? p.x.thread_parts
|
|
125
|
+
: [];
|
|
126
|
+
if (threadParts.length >= 2) {
|
|
127
|
+
md += `\n\n### X Thread (${threadParts.length} parts)\n\n`;
|
|
128
|
+
for (let i = 0; i < threadParts.length; i++) {
|
|
129
|
+
const part = threadParts[i] || {};
|
|
130
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
131
|
+
md += `**${i + 1}/${threadParts.length}.** ${text}\n`;
|
|
132
|
+
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
133
|
+
for (const url of part.media_urls)
|
|
134
|
+
md += ` - ${url}\n`;
|
|
135
|
+
}
|
|
136
|
+
md += `\n`;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
|
|
140
|
+
? p.published_urls
|
|
141
|
+
: {};
|
|
142
|
+
const urlEntries = Object.entries(publishedUrls);
|
|
143
|
+
if (urlEntries.length) {
|
|
144
|
+
md += `\n\n### Published URLs\n\n`;
|
|
145
|
+
for (const [platform, url] of urlEntries) {
|
|
146
|
+
md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
75
149
|
return {
|
|
76
150
|
content: [{ type: "text", text: md }],
|
|
77
151
|
};
|
|
@@ -80,6 +154,7 @@ export function registerPostTools(server, getClient) {
|
|
|
80
154
|
|
|
81
155
|
IMPORTANT — Before calling this tool, make sure you have all required information from the user. If anything is missing, ASK the user before calling:
|
|
82
156
|
|
|
157
|
+
0. **Workspace**: If the user has only one workspace, just use it (no need to ask). If the user has multiple workspaces and has NOT named one in their request, call list_workspaces and ask which workspace to post to before calling this tool. If the user has named a workspace, switch to it once and remember the choice for the rest of the conversation. Never silently pick a workspace when more than one exists. After a successful create/schedule, mention the workspace name in your reply to the user for clarity (e.g. "Scheduled to the Acme workspace for Tuesday 3pm").
|
|
83
158
|
1. **Content/caption**: What text should the post have? If captions differ per platform, use one call with an object: { "default": "fallback text", "linkedin": "long version", "threads": "short version" }. Always prefer one call per topic.
|
|
84
159
|
2. **Channels**: Which platforms to post to? Use list_accounts to show available options if needed.
|
|
85
160
|
3. **Schedule**: When should it be published? (Or save as draft?)
|
|
@@ -91,9 +166,14 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
91
166
|
- Pinterest posts: ALWAYS require an image AND a board_id.
|
|
92
167
|
- Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
|
|
93
168
|
5. **Platform-specific options** (ask only when relevant):
|
|
94
|
-
- Pinterest
|
|
169
|
+
- **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking the user — and do NOT skip Pinterest. Instead:
|
|
170
|
+
1. Call \`get_account\` on the Pinterest account ID — the response includes a \`Pinterest Boards\` table with each board's name and ID.
|
|
171
|
+
2. Use the FIRST board in that list as \`pinterest.board_id\` automatically.
|
|
172
|
+
3. In your reply to the user after the post is created/scheduled, explicitly mention which board you used (e.g. "Posted to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one and I'll move it.") so they can correct course.
|
|
173
|
+
If the user named a board ("post to my Marketing board") or specified one in the request, use that one instead of the first — match on name (case-insensitive) against the boards list.
|
|
95
174
|
- YouTube: Title, privacy status, tags?
|
|
96
175
|
- TikTok: Privacy level?
|
|
176
|
+
- **X threads**: When the user asks for an X/Twitter "thread" (or anything that exceeds 280 chars on X), DO NOT cram it into \`content\` with "1/", "2/" prefixes. Instead pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars). The \`content\` field is then ignored for X. For a single tweet, omit \`thread_parts\` and use \`content\` normally.
|
|
97
177
|
|
|
98
178
|
Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
|
|
99
179
|
|
|
@@ -140,6 +220,12 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
140
220
|
}).optional().describe("TikTok options"),
|
|
141
221
|
x: z.object({
|
|
142
222
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
223
|
+
paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
|
|
224
|
+
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
225
|
+
thread_parts: z.array(z.object({
|
|
226
|
+
text: z.string().describe("Tweet text (≤ 280 chars)"),
|
|
227
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
|
|
228
|
+
})).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Per-part media_urls override top-level media for that specific tweet. For a single tweet, omit thread_parts and use content."),
|
|
143
229
|
}).optional().describe("X (Twitter) options"),
|
|
144
230
|
}, async (params) => {
|
|
145
231
|
const result = await getClient().createPost({ ...params, source: "mcp" });
|
|
@@ -149,24 +235,36 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
149
235
|
};
|
|
150
236
|
}
|
|
151
237
|
const p = result.data;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
md
|
|
159
|
-
|
|
160
|
-
md +=
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
238
|
+
// The post is already created by the time this runs. If response
|
|
239
|
+
// formatting throws, we must NOT surface it as a tool error — agents
|
|
240
|
+
// retry on errors and that produces orphan duplicate posts.
|
|
241
|
+
try {
|
|
242
|
+
const schedAt = p.schedule_at ?? p.scheduled_at;
|
|
243
|
+
const channels = selectedChannels(p);
|
|
244
|
+
let md = `## Post Created\n\n`;
|
|
245
|
+
md += `| Field | Value |\n`;
|
|
246
|
+
md += `|-------|-------|\n`;
|
|
247
|
+
md += `| **ID** | \`${p.id}\` |\n`;
|
|
248
|
+
md += `| **Status** | ${capitalize(p.status || "draft")} |\n`;
|
|
249
|
+
if (schedAt)
|
|
250
|
+
md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
|
|
251
|
+
if (channels.length)
|
|
252
|
+
md += `| **Channels** | ${channels.join(", ")} |\n`;
|
|
253
|
+
md += `\n**Content:** ${truncate(p.content, 100)}`;
|
|
254
|
+
return { content: [{ type: "text", text: md }] };
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
// Fall back to the minimum the agent needs to confirm success — never error.
|
|
258
|
+
return {
|
|
259
|
+
content: [{ type: "text", text: `## Post Created\n\n- **ID:** \`${p.id}\`\n- **Status:** ${p.status || "draft"}\n\n_Post was created successfully. Use \`get_post\` with the ID above to see full details._` }],
|
|
260
|
+
};
|
|
261
|
+
}
|
|
165
262
|
});
|
|
166
263
|
server.tool("create_and_publish_post", `Create a new post and publish it immediately (no scheduling).
|
|
167
264
|
|
|
168
265
|
IMPORTANT — Before calling this tool, make sure you have all required information. If anything is missing, ASK the user first:
|
|
169
266
|
|
|
267
|
+
0. **Workspace**: If the user has only one workspace, just use it (no need to ask). If the user has multiple workspaces and has NOT named one, call list_workspaces and ask before publishing. If the user has named a workspace, switch to it once and remember it. Never silently pick a workspace when more than one exists — published posts are public. After publishing, mention the workspace name in your reply (e.g. "Published to the Acme workspace LinkedIn Page").
|
|
170
268
|
1. **Content/caption**: What text? If captions differ per platform, use one call with an object: { "default": "fallback", "linkedin": "long", "threads": "short" }.
|
|
171
269
|
2. **Channels**: Which platforms? Use list_accounts if needed.
|
|
172
270
|
3. **Media** (REQUIRED for some types — same rules as create_post):
|
|
@@ -175,6 +273,8 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
175
273
|
- Instagram/TikTok posts: ALWAYS need at least one image or video.
|
|
176
274
|
- Pinterest: ALWAYS need an image + board_id.
|
|
177
275
|
- Ask the user which media to use. Use list_media to show options.
|
|
276
|
+
4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
|
|
277
|
+
5. **X threads**: When the user asks for an X/Twitter "thread" (or anything > 280 chars on X), pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
|
|
178
278
|
|
|
179
279
|
Do NOT call without required media — it will fail.`, {
|
|
180
280
|
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."),
|
|
@@ -201,6 +301,15 @@ Do NOT call without required media — it will fail.`, {
|
|
|
201
301
|
tiktok: z.object({
|
|
202
302
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
203
303
|
}).optional().describe("TikTok options"),
|
|
304
|
+
x: z.object({
|
|
305
|
+
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
306
|
+
paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
|
|
307
|
+
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
308
|
+
thread_parts: z.array(z.object({
|
|
309
|
+
text: z.string().describe("Tweet text (≤ 280 chars)"),
|
|
310
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
|
|
311
|
+
})).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Per-part media_urls override top-level media."),
|
|
312
|
+
}).optional().describe("X (Twitter) options"),
|
|
204
313
|
}, async (params) => {
|
|
205
314
|
const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
|
|
206
315
|
if (result.error) {
|
|
@@ -209,19 +318,28 @@ Do NOT call without required media — it will fail.`, {
|
|
|
209
318
|
};
|
|
210
319
|
}
|
|
211
320
|
const p = result.data;
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
md += `| **
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
321
|
+
try {
|
|
322
|
+
const channels = selectedChannels(p);
|
|
323
|
+
let md = `## Post Created & Publishing\n\n`;
|
|
324
|
+
md += `| Field | Value |\n`;
|
|
325
|
+
md += `|-------|-------|\n`;
|
|
326
|
+
md += `| **ID** | \`${p.id}\` |\n`;
|
|
327
|
+
md += `| **Status** | ${capitalize(p.status || "publishing")} |\n`;
|
|
328
|
+
if (channels.length)
|
|
329
|
+
md += `| **Channels** | ${channels.join(", ")} |\n`;
|
|
330
|
+
md += `\n**Content:** ${truncate(p.content, 100)}`;
|
|
331
|
+
return { content: [{ type: "text", text: md }] };
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// The post is already queued; never let a formatting throw look like an API error.
|
|
335
|
+
return {
|
|
336
|
+
content: [{ type: "text", text: `## Post Created & Publishing\n\n- **ID:** \`${p.id}\`\n- **Status:** ${p.status || "publishing"}\n\n_Post was queued successfully. Use \`get_post\` with the ID above for full details._` }],
|
|
337
|
+
};
|
|
338
|
+
}
|
|
223
339
|
});
|
|
224
|
-
server.tool("update_post",
|
|
340
|
+
server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
|
|
341
|
+
|
|
342
|
+
**X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread.`, {
|
|
225
343
|
id: z.string().describe("The post ID to update"),
|
|
226
344
|
content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post content. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
|
|
227
345
|
scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
|
|
@@ -234,6 +352,15 @@ Do NOT call without required media — it will fail.`, {
|
|
|
234
352
|
z.array(z.string()),
|
|
235
353
|
z.record(z.string(), z.array(z.string())),
|
|
236
354
|
]).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."),
|
|
355
|
+
x: z.object({
|
|
356
|
+
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
357
|
+
paid_partnership: z.boolean().optional(),
|
|
358
|
+
made_with_ai: z.boolean().optional(),
|
|
359
|
+
thread_parts: z.array(z.object({
|
|
360
|
+
text: z.string().describe("Tweet text (≤ 280 chars)"),
|
|
361
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media URLs (max 4)"),
|
|
362
|
+
})).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread, or `null` to revert to single-tweet mode."),
|
|
363
|
+
}).optional().describe("X (Twitter) options"),
|
|
237
364
|
}, async ({ id, ...data }) => {
|
|
238
365
|
const result = await getClient().updatePost(id, data);
|
|
239
366
|
if (result.error) {
|
|
@@ -242,13 +369,14 @@ Do NOT call without required media — it will fail.`, {
|
|
|
242
369
|
};
|
|
243
370
|
}
|
|
244
371
|
const p = result.data;
|
|
372
|
+
const schedAt = p.schedule_at ?? p.scheduled_at;
|
|
245
373
|
let md = `## Post Updated\n\n`;
|
|
246
374
|
md += `| Field | Value |\n`;
|
|
247
375
|
md += `|-------|-------|\n`;
|
|
248
376
|
md += `| **ID** | \`${p.id}\` |\n`;
|
|
249
377
|
md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
|
|
250
|
-
if (
|
|
251
|
-
md += `| **Scheduled** | ${formatDateTime(
|
|
378
|
+
if (schedAt)
|
|
379
|
+
md += `| **Scheduled** | ${formatDateTime(schedAt)} |\n`;
|
|
252
380
|
return {
|
|
253
381
|
content: [{ type: "text", text: md }],
|
|
254
382
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
export function registerWorkspaceTools(server, workspaceClients, sessionState) {
|
|
3
|
-
server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active.", {}, async () => {
|
|
3
|
+
server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, async () => {
|
|
4
4
|
const workspaces = await Promise.all(workspaceClients.map(async (wc, index) => {
|
|
5
5
|
try {
|
|
6
6
|
const result = await wc.client.listAccounts();
|
|
@@ -39,7 +39,7 @@ export function registerWorkspaceTools(server, workspaceClients, sessionState) {
|
|
|
39
39
|
md += `\nUse **switch_workspace** with the workspace number to change the active workspace.`;
|
|
40
40
|
return { content: [{ type: "text", text: md }] };
|
|
41
41
|
});
|
|
42
|
-
server.tool("switch_workspace", "Switch the active workspace. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace.
|
|
42
|
+
server.tool("switch_workspace", "Switch the active workspace. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace. Only call this when the user has explicitly named the target workspace, or after the user has picked one from list_workspaces. Never call switch_workspace silently when the user hasn't specified a workspace — ask first.", {
|
|
43
43
|
workspace_number: z.string().describe("The workspace number from list_workspaces (1, 2, 3, etc.)"),
|
|
44
44
|
}, async ({ workspace_number }) => {
|
|
45
45
|
const index = parseInt(workspace_number, 10) - 1;
|
package/build/types.d.ts
CHANGED
|
@@ -14,6 +14,17 @@ export interface Post {
|
|
|
14
14
|
channels: string[];
|
|
15
15
|
media: string[];
|
|
16
16
|
created_at: string;
|
|
17
|
+
x?: {
|
|
18
|
+
reply_settings?: string;
|
|
19
|
+
paid_partnership?: boolean;
|
|
20
|
+
made_with_ai?: boolean;
|
|
21
|
+
thread_parts?: Array<{
|
|
22
|
+
id?: string;
|
|
23
|
+
text: string;
|
|
24
|
+
media_urls?: string[];
|
|
25
|
+
}>;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
17
28
|
}
|
|
18
29
|
export interface MediaItem {
|
|
19
30
|
id: string;
|
package/package.json
CHANGED