@omnisocials/mcp-server 1.8.0 → 1.9.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/build/client.d.ts +66 -3
- package/build/client.js +12 -3
- package/build/index.js +1 -1
- package/build/tools/analytics.js +6 -5
- package/build/tools/media.js +31 -5
- package/build/tools/posts.js +205 -3
- package/build/types.d.ts +30 -7
- package/package.json +1 -1
package/build/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApiResponse } from "./types.js";
|
|
1
|
+
import type { ApiResponse, MediaItem, PdfUploadResult } from "./types.js";
|
|
2
2
|
export declare function fetchImageAsBase64(url: string): Promise<{
|
|
3
3
|
data: string;
|
|
4
4
|
mimeType: string;
|
|
@@ -32,6 +32,44 @@ export interface XPostOptions {
|
|
|
32
32
|
export interface XPostOptionsUpdate extends Omit<XPostOptions, "thread_parts"> {
|
|
33
33
|
thread_parts?: XThreadPartInput[] | null;
|
|
34
34
|
}
|
|
35
|
+
export interface BlueskyThreadPartInput {
|
|
36
|
+
/** Post text — ≤ 300 characters (counted as graphemes; one emoji = 1). */
|
|
37
|
+
text: string;
|
|
38
|
+
/** Optional per-part media as Library IDs from upload_media (max 4). */
|
|
39
|
+
media_ids?: string[];
|
|
40
|
+
/** Optional per-part media as external URLs (max 4). */
|
|
41
|
+
media_urls?: string[];
|
|
42
|
+
}
|
|
43
|
+
export interface BlueskyPostOptions {
|
|
44
|
+
/** Provide 2–25 parts to publish as a thread. Omit for a single post. */
|
|
45
|
+
thread_parts?: BlueskyThreadPartInput[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Update-side variant: `thread_parts: null` clears the thread (revert to a
|
|
49
|
+
* single post); `undefined` leaves the existing thread untouched.
|
|
50
|
+
*/
|
|
51
|
+
export interface BlueskyPostOptionsUpdate {
|
|
52
|
+
thread_parts?: BlueskyThreadPartInput[] | null;
|
|
53
|
+
}
|
|
54
|
+
export interface MastodonThreadPartInput {
|
|
55
|
+
/** Status text — ≤ 500 characters by default (some instances allow more). */
|
|
56
|
+
text: string;
|
|
57
|
+
/** Optional per-part media as Library IDs from upload_media (max 4). */
|
|
58
|
+
media_ids?: string[];
|
|
59
|
+
/** Optional per-part media as external URLs (max 4). */
|
|
60
|
+
media_urls?: string[];
|
|
61
|
+
}
|
|
62
|
+
export interface MastodonPostOptions {
|
|
63
|
+
/** Provide 2–25 parts to publish as a thread. Omit for a single status. */
|
|
64
|
+
thread_parts?: MastodonThreadPartInput[];
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Update-side variant: `thread_parts: null` clears the thread (revert to a
|
|
68
|
+
* single status); `undefined` leaves the existing thread untouched.
|
|
69
|
+
*/
|
|
70
|
+
export interface MastodonPostOptionsUpdate {
|
|
71
|
+
thread_parts?: MastodonThreadPartInput[] | null;
|
|
72
|
+
}
|
|
35
73
|
export declare class OmniSocialsClient {
|
|
36
74
|
private baseUrl;
|
|
37
75
|
private apiKey;
|
|
@@ -43,6 +81,10 @@ export declare class OmniSocialsClient {
|
|
|
43
81
|
offset?: string;
|
|
44
82
|
}): Promise<ApiResponse<unknown>>;
|
|
45
83
|
getPost(id: string): Promise<ApiResponse<unknown>>;
|
|
84
|
+
getRecentPlatformPosts(params?: {
|
|
85
|
+
limit?: string;
|
|
86
|
+
platforms?: string;
|
|
87
|
+
}): Promise<ApiResponse<unknown>>;
|
|
46
88
|
createPost(data: {
|
|
47
89
|
content: string | Record<string, string>;
|
|
48
90
|
channels?: string[];
|
|
@@ -66,8 +108,13 @@ export declare class OmniSocialsClient {
|
|
|
66
108
|
pinterest?: Record<string, unknown>;
|
|
67
109
|
youtube?: Record<string, unknown>;
|
|
68
110
|
instagram?: Record<string, unknown>;
|
|
111
|
+
facebook?: Record<string, unknown>;
|
|
112
|
+
linkedin?: Record<string, unknown>;
|
|
113
|
+
linkedin_page?: Record<string, unknown>;
|
|
69
114
|
tiktok?: Record<string, unknown>;
|
|
70
115
|
x?: XPostOptions;
|
|
116
|
+
bluesky?: BlueskyPostOptions;
|
|
117
|
+
mastodon?: MastodonPostOptions;
|
|
71
118
|
google_business?: Record<string, unknown>;
|
|
72
119
|
}): Promise<ApiResponse<unknown>>;
|
|
73
120
|
createAndPublishPost(data: {
|
|
@@ -92,8 +139,13 @@ export declare class OmniSocialsClient {
|
|
|
92
139
|
pinterest?: Record<string, unknown>;
|
|
93
140
|
youtube?: Record<string, unknown>;
|
|
94
141
|
instagram?: Record<string, unknown>;
|
|
142
|
+
facebook?: Record<string, unknown>;
|
|
143
|
+
linkedin?: Record<string, unknown>;
|
|
144
|
+
linkedin_page?: Record<string, unknown>;
|
|
95
145
|
tiktok?: Record<string, unknown>;
|
|
96
146
|
x?: XPostOptions;
|
|
147
|
+
bluesky?: BlueskyPostOptions;
|
|
148
|
+
mastodon?: MastodonPostOptions;
|
|
97
149
|
google_business?: Record<string, unknown>;
|
|
98
150
|
}): Promise<ApiResponse<unknown>>;
|
|
99
151
|
updatePost(id: string, data: {
|
|
@@ -114,8 +166,13 @@ export declare class OmniSocialsClient {
|
|
|
114
166
|
pinterest?: Record<string, unknown>;
|
|
115
167
|
youtube?: Record<string, unknown>;
|
|
116
168
|
instagram?: Record<string, unknown>;
|
|
169
|
+
facebook?: Record<string, unknown>;
|
|
170
|
+
linkedin?: Record<string, unknown>;
|
|
171
|
+
linkedin_page?: Record<string, unknown>;
|
|
117
172
|
tiktok?: Record<string, unknown>;
|
|
118
173
|
x?: XPostOptionsUpdate;
|
|
174
|
+
bluesky?: BlueskyPostOptionsUpdate;
|
|
175
|
+
mastodon?: MastodonPostOptionsUpdate;
|
|
119
176
|
google_business?: Record<string, unknown>;
|
|
120
177
|
}): Promise<ApiResponse<unknown>>;
|
|
121
178
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
|
@@ -133,14 +190,20 @@ export declare class OmniSocialsClient {
|
|
|
133
190
|
filename?: string;
|
|
134
191
|
name?: string;
|
|
135
192
|
folder?: string;
|
|
136
|
-
}): Promise<ApiResponse<
|
|
193
|
+
}): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
|
|
194
|
+
compatibility?: unknown;
|
|
195
|
+
message?: string;
|
|
196
|
+
}>;
|
|
137
197
|
uploadMediaFromBase64(data: {
|
|
138
198
|
data: string;
|
|
139
199
|
mime_type: string;
|
|
140
200
|
filename?: string;
|
|
141
201
|
name?: string;
|
|
142
202
|
folder?: string;
|
|
143
|
-
}): Promise<ApiResponse<
|
|
203
|
+
}): Promise<ApiResponse<MediaItem> & Partial<PdfUploadResult> & {
|
|
204
|
+
compatibility?: unknown;
|
|
205
|
+
message?: string;
|
|
206
|
+
}>;
|
|
144
207
|
/**
|
|
145
208
|
* Preflight a file's compatibility with the workspace's connected platforms
|
|
146
209
|
* BEFORE uploading. Provide one of: a public `url`, an existing `media_id`,
|
package/build/client.js
CHANGED
|
@@ -135,6 +135,12 @@ export class OmniSocialsClient {
|
|
|
135
135
|
async getPost(id) {
|
|
136
136
|
return this.request("GET", `/posts/${id}`);
|
|
137
137
|
}
|
|
138
|
+
// Recent posts fetched live from the connected platform APIs (including
|
|
139
|
+
// content published outside OmniSocials). The fallback for brand-new
|
|
140
|
+
// workspaces where listPosts is empty. Requires the analytics:read scope.
|
|
141
|
+
async getRecentPlatformPosts(params) {
|
|
142
|
+
return this.request("GET", "/posts/recent-platform", undefined, params);
|
|
143
|
+
}
|
|
138
144
|
async createPost(data) {
|
|
139
145
|
return this.request("POST", "/posts/create", data);
|
|
140
146
|
}
|
|
@@ -161,10 +167,13 @@ export class OmniSocialsClient {
|
|
|
161
167
|
async listMedia(params) {
|
|
162
168
|
return this.request("GET", "/media", undefined, params);
|
|
163
169
|
}
|
|
170
|
+
// Videos up to 1 GB: the API streams files >100 MB in the background and
|
|
171
|
+
// responds with the item in `processing` state. A PDF is split into image
|
|
172
|
+
// slides and the response carries the extra PdfUploadResult fields (`slides`,
|
|
173
|
+
// `media_ids`, `pdf`) alongside `data` (the first slide). Every response also
|
|
174
|
+
// includes a `compatibility` block listing connected platforms that would
|
|
175
|
+
// reject the file.
|
|
164
176
|
async uploadMedia(data) {
|
|
165
|
-
// Videos up to 1 GB: the API streams files >100 MB in the background and
|
|
166
|
-
// responds with the item in `processing` state. Includes a `compatibility`
|
|
167
|
-
// block listing connected platforms that would reject the file.
|
|
168
177
|
return this.request("POST", "/media/upload-from-url", data);
|
|
169
178
|
}
|
|
170
179
|
async uploadMediaFromBase64(data) {
|
package/build/index.js
CHANGED
|
@@ -27,7 +27,7 @@ const sessionState = { activeIndex: 0 };
|
|
|
27
27
|
const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
|
|
28
28
|
const server = new McpServer({
|
|
29
29
|
name: "OmniSocials",
|
|
30
|
-
version: "1.
|
|
30
|
+
version: "1.9.0",
|
|
31
31
|
});
|
|
32
32
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
33
33
|
registerPostTools(server, getActiveClient);
|
package/build/tools/analytics.js
CHANGED
|
@@ -12,9 +12,10 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
12
12
|
}
|
|
13
13
|
const d = result.data;
|
|
14
14
|
// API returns { post_id, platforms: { <platform>: { metrics: {...} } } }.
|
|
15
|
-
//
|
|
16
|
-
// (
|
|
17
|
-
//
|
|
15
|
+
// Each platform's metrics are already summed across thread parts server-side
|
|
16
|
+
// (thread posts publish as a chain), so we just total across platforms here.
|
|
17
|
+
// The legacy flat shape (d.impressions / d.platform_stats) doesn't exist on
|
|
18
|
+
// this endpoint and reading those keys returned "—" for every field.
|
|
18
19
|
const platforms = d?.platforms || {};
|
|
19
20
|
const platformEntries = Object.entries(platforms);
|
|
20
21
|
if (platformEntries.length === 0) {
|
|
@@ -79,8 +80,8 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
// API returns { data: [{ post_id, platforms: { <platform>: { metrics } } }] }.
|
|
82
|
-
//
|
|
83
|
-
//
|
|
83
|
+
// Per-platform metrics are already thread-summed server-side; collapse each
|
|
84
|
+
// post's per-platform metrics into totals, mirroring get_post_analytics.
|
|
84
85
|
const entries = result.data || [];
|
|
85
86
|
const rows = entries.map((entry) => {
|
|
86
87
|
const platforms = entry?.platforms || {};
|
package/build/tools/media.js
CHANGED
|
@@ -55,7 +55,9 @@ export function registerMediaTools(server, getClient) {
|
|
|
55
55
|
content.push({ type: "text", text: md });
|
|
56
56
|
return { content };
|
|
57
57
|
});
|
|
58
|
-
server.tool("upload_media", `Upload media to the library. Accepts EITHER a public URL OR base64-encoded image data (e.g. when the user pastes an image directly in chat). Supported: JPEG, PNG, GIF, WebP, MP4, MOV, AVI.
|
|
58
|
+
server.tool("upload_media", `Upload media to the library. Accepts EITHER a public URL OR base64-encoded image data (e.g. when the user pastes an image directly in chat). Supported: JPEG, PNG, GIF, WebP, MP4, MOV, AVI, and PDF.
|
|
59
|
+
|
|
60
|
+
PDF = carousel: upload a PDF (as a 'url' or as base64_data with mime_type "application/pdf") and it is split into one image slide per page (max 20 pages). The response lists a Media ID for every slide — pass ALL of them, in order, as media_ids to create_post to post the deck as a carousel. On LinkedIn the slides post as a native swipeable DOCUMENT; on Instagram, TikTok, Threads and Pinterest as an image carousel. This is the way to let a user post an existing slide deck (Canva/PowerPoint/Figma exported to PDF) as a carousel.
|
|
59
61
|
|
|
60
62
|
Size limits: base64 and direct uploads are capped at 100 MB (and base64 inflates the request body, so the practical ceiling is ~75 MB). For anything larger — up to 1 GB — use ONE of: (a) pass a public 'url' and the server fetches it (handles up to 1 GB), or (b) have the user upload the file in the OmniSocials Library UI at https://app.omnisocials.com/library . IMPORTANT: if the user has a large LOCAL file (over ~100 MB) with no public URL, do NOT tell them to compress or shrink it — instead point them to the Library UI link above, which uploads files up to 1 GB directly.
|
|
61
63
|
|
|
@@ -64,9 +66,9 @@ Large videos (over 100 MB) are processed in the background: the response comes b
|
|
|
64
66
|
Compatibility: the response includes a "compatibility" summary of any CONNECTED platforms that would reject the file (e.g. too large for Instagram). If there are warnings, RELAY them to the user and ask whether to continue before using the media in a post — the file still uploads and can post to the platforms that DO accept it. To check BEFORE uploading, use check_media_compatibility first.
|
|
65
67
|
|
|
66
68
|
When the user provides an image in the conversation (not a URL), use base64_data + mime_type to upload it directly.`, {
|
|
67
|
-
url: z.string().optional().describe("Public URL of the media file to upload. Use this OR base64_data, not both."),
|
|
68
|
-
base64_data: z.string().optional().describe("Base64-encoded file data. Use when the user provides an image directly in chat rather than a URL."),
|
|
69
|
-
mime_type: z.string().optional().describe("MIME type of the file (e.g. 'image/jpeg', 'image/png'). Required when using base64_data."),
|
|
69
|
+
url: z.string().optional().describe("Public URL of the media file to upload (image, video, or PDF). Use this OR base64_data, not both."),
|
|
70
|
+
base64_data: z.string().optional().describe("Base64-encoded file data. Use when the user provides an image or PDF directly in chat rather than a URL."),
|
|
71
|
+
mime_type: z.string().optional().describe("MIME type of the file (e.g. 'image/jpeg', 'image/png', 'application/pdf'). Required when using base64_data."),
|
|
70
72
|
filename: z.string().optional().describe("Optional filename for the uploaded media"),
|
|
71
73
|
name: z.string().optional().describe('Human-readable label so you can find this asset by name later instead of re-uploading it, e.g. "pp-play5get50". Strongly recommended on every upload.'),
|
|
72
74
|
folder: z.string().optional().describe('Optional folder name to file this asset under (created at the top level if it does not exist), e.g. "win-graphics". See list_folders.'),
|
|
@@ -94,8 +96,32 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
94
96
|
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
|
-
const m = result.data;
|
|
98
99
|
const compatibility = result.compatibility;
|
|
100
|
+
// PDF upload: the server split it into image slides. Surface every slide
|
|
101
|
+
// id and tell the model to pass them ALL to create_post as one carousel.
|
|
102
|
+
const mediaIds = result.media_ids;
|
|
103
|
+
const slides = result.slides;
|
|
104
|
+
const pdf = result.pdf;
|
|
105
|
+
if (Array.isArray(mediaIds) && mediaIds.length && Array.isArray(slides)) {
|
|
106
|
+
let md = `## PDF Uploaded as Carousel\n\n`;
|
|
107
|
+
md += `Split into **${mediaIds.length} image ${mediaIds.length === 1 ? "slide" : "slides"}**`;
|
|
108
|
+
if (pdf?.truncated) {
|
|
109
|
+
md += ` (first ${pdf.rendered_pages} of ${pdf.total_pages} pages — max 20)`;
|
|
110
|
+
}
|
|
111
|
+
md += `.\n\n`;
|
|
112
|
+
md += `| # | Slide Media ID |\n|---|----------------|\n`;
|
|
113
|
+
slides.forEach((s, i) => {
|
|
114
|
+
md += `| ${i + 1} | \`${s.id}\` |\n`;
|
|
115
|
+
});
|
|
116
|
+
md += `\n**Pass ALL of these to \`create_post\` as \`media_ids\`, in this order**, to post the deck as a carousel:\n\n`;
|
|
117
|
+
md += `\`\`\`\nmedia_ids: [${mediaIds.map((id) => `"${id}"`).join(", ")}]\n\`\`\`\n\n`;
|
|
118
|
+
md += `On LinkedIn this posts as a swipeable **document** carousel; on Instagram, TikTok, Threads and Pinterest as an **image** carousel. Platforms that do not support carousels use the first slide.`;
|
|
119
|
+
if (compatibility && compatibility.compatible === false && compatibility.summary) {
|
|
120
|
+
md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before posting.`;
|
|
121
|
+
}
|
|
122
|
+
return { content: [{ type: "text", text: md }] };
|
|
123
|
+
}
|
|
124
|
+
const m = result.data;
|
|
99
125
|
const isProcessing = m.status === "processing";
|
|
100
126
|
let md = isProcessing ? `## Media Processing\n\n` : `## Media Uploaded\n\n`;
|
|
101
127
|
md += `| Field | Value |\n`;
|
package/build/tools/posts.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatDateTime, truncate, capitalize } from "../client.js";
|
|
2
|
+
import { formatDateTime, truncate, capitalize, formatNumber } from "../client.js";
|
|
3
|
+
// Reusable per-platform option objects for the "first comment" feature — text
|
|
4
|
+
// auto-posted as a comment on the post right after it publishes (hashtags out
|
|
5
|
+
// of the caption, "link in first comment", etc.). Only platforms with a
|
|
6
|
+
// sanctioned comment API. Facebook/LinkedIn have no other API options today,
|
|
7
|
+
// so these objects exist solely to carry `first_comment`.
|
|
8
|
+
const FACEBOOK_OPTIONS = z
|
|
9
|
+
.object({
|
|
10
|
+
first_comment: z
|
|
11
|
+
.string()
|
|
12
|
+
.max(8000)
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Text auto-posted as the first comment on the Facebook post right after it publishes. Page posts only (the API cannot comment on personal-profile posts). Not posted for Stories."),
|
|
15
|
+
})
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("Facebook options");
|
|
18
|
+
const LINKEDIN_OPTIONS = z
|
|
19
|
+
.object({
|
|
20
|
+
first_comment: z
|
|
21
|
+
.string()
|
|
22
|
+
.max(1250)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Text auto-posted as the first comment on the LinkedIn Profile post right after it publishes. Common for 'link in first comment' to avoid the in-caption link reach penalty."),
|
|
25
|
+
})
|
|
26
|
+
.optional()
|
|
27
|
+
.describe("LinkedIn Profile options");
|
|
28
|
+
const LINKEDIN_PAGE_OPTIONS = z
|
|
29
|
+
.object({
|
|
30
|
+
first_comment: z
|
|
31
|
+
.string()
|
|
32
|
+
.max(1250)
|
|
33
|
+
.optional()
|
|
34
|
+
.describe("Text auto-posted as the first comment on the LinkedIn Company Page post right after it publishes."),
|
|
35
|
+
})
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("LinkedIn Company Page options");
|
|
3
38
|
// Render full content for get_post — preserves per-platform overrides so Claude
|
|
4
39
|
// can see custom captions (e.g. a shorter X version) instead of only `default`.
|
|
5
40
|
function renderContent(content) {
|
|
@@ -43,6 +78,11 @@ function displayDate(p) {
|
|
|
43
78
|
// or jump to a published post. Drafts and scheduled posts open in the editor
|
|
44
79
|
// at /create-post/:id; published posts open in the details view at /posts/:id.
|
|
45
80
|
function postAppUrl(p) {
|
|
81
|
+
// Prefer the server-provided app_url — it's built from the environment's
|
|
82
|
+
// client URL, so it's correct on staging/production. Fall back to a
|
|
83
|
+
// hardcoded production URL for older API responses that don't return it.
|
|
84
|
+
if (typeof p?.app_url === "string" && p.app_url)
|
|
85
|
+
return p.app_url;
|
|
46
86
|
const id = p?.id;
|
|
47
87
|
if (!id)
|
|
48
88
|
return null;
|
|
@@ -152,6 +192,40 @@ export function registerPostTools(server, getClient) {
|
|
|
152
192
|
md += `\n`;
|
|
153
193
|
}
|
|
154
194
|
}
|
|
195
|
+
// Bluesky thread: when present, the canonical post text lives here, not in `content`.
|
|
196
|
+
const bskyThreadParts = Array.isArray(p.bluesky?.thread_parts)
|
|
197
|
+
? p.bluesky.thread_parts
|
|
198
|
+
: [];
|
|
199
|
+
if (bskyThreadParts.length >= 2) {
|
|
200
|
+
md += `\n\n### Bluesky Thread (${bskyThreadParts.length} parts)\n\n`;
|
|
201
|
+
for (let i = 0; i < bskyThreadParts.length; i++) {
|
|
202
|
+
const part = bskyThreadParts[i] || {};
|
|
203
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
204
|
+
md += `**${i + 1}/${bskyThreadParts.length}.** ${text}\n`;
|
|
205
|
+
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
206
|
+
for (const url of part.media_urls)
|
|
207
|
+
md += ` - ${url}\n`;
|
|
208
|
+
}
|
|
209
|
+
md += `\n`;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Mastodon thread: when present, the canonical status text lives here, not in `content`.
|
|
213
|
+
const mastoThreadParts = Array.isArray(p.mastodon?.thread_parts)
|
|
214
|
+
? p.mastodon.thread_parts
|
|
215
|
+
: [];
|
|
216
|
+
if (mastoThreadParts.length >= 2) {
|
|
217
|
+
md += `\n\n### Mastodon Thread (${mastoThreadParts.length} parts)\n\n`;
|
|
218
|
+
for (let i = 0; i < mastoThreadParts.length; i++) {
|
|
219
|
+
const part = mastoThreadParts[i] || {};
|
|
220
|
+
const text = typeof part.text === "string" ? part.text : "";
|
|
221
|
+
md += `**${i + 1}/${mastoThreadParts.length}.** ${text}\n`;
|
|
222
|
+
if (Array.isArray(part.media_urls) && part.media_urls.length) {
|
|
223
|
+
for (const url of part.media_urls)
|
|
224
|
+
md += ` - ${url}\n`;
|
|
225
|
+
}
|
|
226
|
+
md += `\n`;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
155
229
|
const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
|
|
156
230
|
? p.published_urls
|
|
157
231
|
: {};
|
|
@@ -162,6 +236,23 @@ export function registerPostTools(server, getClient) {
|
|
|
162
236
|
md += `- **${capitalize(platform.replace(/_/g, " "))}**: ${url}\n`;
|
|
163
237
|
}
|
|
164
238
|
}
|
|
239
|
+
// First comments: nested under each platform object by the API. Show the
|
|
240
|
+
// configured text plus, once published, the outcome (posted/failed/skipped).
|
|
241
|
+
const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube"];
|
|
242
|
+
const firstCommentRows = [];
|
|
243
|
+
for (const platform of firstCommentPlatforms) {
|
|
244
|
+
const opts = p[platform];
|
|
245
|
+
const text = opts && typeof opts === "object" ? opts.first_comment : undefined;
|
|
246
|
+
if (typeof text !== "string" || !text.trim())
|
|
247
|
+
continue;
|
|
248
|
+
const result = opts.first_comment_result;
|
|
249
|
+
const status = result && typeof result === "object" ? result.status : undefined;
|
|
250
|
+
const statusLabel = status ? ` — _${status}${result?.error ? `: ${result.error}` : ""}_` : "";
|
|
251
|
+
firstCommentRows.push(`- **${capitalize(platform.replace(/_/g, " "))}**${statusLabel}: ${truncate(text, 200)}`);
|
|
252
|
+
}
|
|
253
|
+
if (firstCommentRows.length) {
|
|
254
|
+
md += `\n\n### First Comments\n\n${firstCommentRows.join("\n")}\n`;
|
|
255
|
+
}
|
|
165
256
|
return {
|
|
166
257
|
content: [{ type: "text", text: md }],
|
|
167
258
|
};
|
|
@@ -250,13 +341,18 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
250
341
|
made_for_kids: z.boolean().optional(),
|
|
251
342
|
notify_subscribers: z.boolean().optional(),
|
|
252
343
|
contains_synthetic_media: z.boolean().optional(),
|
|
344
|
+
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."),
|
|
253
345
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
254
346
|
instagram: z.object({
|
|
255
347
|
share_to_feed: z.boolean().optional(),
|
|
256
348
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
|
|
257
349
|
thumb_offset: z.number().optional(),
|
|
258
350
|
cover_url: z.string().optional(),
|
|
259
|
-
|
|
351
|
+
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
|
|
352
|
+
}).optional().describe("Instagram options"),
|
|
353
|
+
facebook: FACEBOOK_OPTIONS,
|
|
354
|
+
linkedin: LINKEDIN_OPTIONS,
|
|
355
|
+
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
260
356
|
tiktok: z.object({
|
|
261
357
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
262
358
|
disable_comment: z.boolean().optional(),
|
|
@@ -276,6 +372,20 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
276
372
|
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
|
|
277
373
|
})).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."),
|
|
278
374
|
}).optional().describe("X (Twitter) options"),
|
|
375
|
+
bluesky: z.object({
|
|
376
|
+
thread_parts: z.array(z.object({
|
|
377
|
+
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
378
|
+
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."),
|
|
379
|
+
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."),
|
|
380
|
+
})).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."),
|
|
381
|
+
}).optional().describe("Bluesky options"),
|
|
382
|
+
mastodon: z.object({
|
|
383
|
+
thread_parts: z.array(z.object({
|
|
384
|
+
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
385
|
+
media_ids: z.array(z.string()).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4)."),
|
|
386
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-status media as external URLs (max 4)."),
|
|
387
|
+
})).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."),
|
|
388
|
+
}).optional().describe("Mastodon options"),
|
|
279
389
|
google_business: z.object({
|
|
280
390
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
|
|
281
391
|
cta: z.object({
|
|
@@ -394,7 +504,14 @@ Do NOT call without required media — it will fail.`, {
|
|
|
394
504
|
title: z.string().optional().describe("Short title shown on YouTube."),
|
|
395
505
|
tags: z.array(z.string()).optional(),
|
|
396
506
|
privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
|
|
507
|
+
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."),
|
|
397
508
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
509
|
+
instagram: z.object({
|
|
510
|
+
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Common for keeping hashtags out of the caption. Not posted for Stories."),
|
|
511
|
+
}).optional().describe("Instagram options"),
|
|
512
|
+
facebook: FACEBOOK_OPTIONS,
|
|
513
|
+
linkedin: LINKEDIN_OPTIONS,
|
|
514
|
+
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
398
515
|
tiktok: z.object({
|
|
399
516
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
400
517
|
}).optional().describe("TikTok options"),
|
|
@@ -408,6 +525,20 @@ Do NOT call without required media — it will fail.`, {
|
|
|
408
525
|
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
|
|
409
526
|
})).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."),
|
|
410
527
|
}).optional().describe("X (Twitter) options"),
|
|
528
|
+
bluesky: z.object({
|
|
529
|
+
thread_parts: z.array(z.object({
|
|
530
|
+
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
531
|
+
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."),
|
|
532
|
+
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."),
|
|
533
|
+
})).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."),
|
|
534
|
+
}).optional().describe("Bluesky options"),
|
|
535
|
+
mastodon: z.object({
|
|
536
|
+
thread_parts: z.array(z.object({
|
|
537
|
+
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
538
|
+
media_ids: z.array(z.string()).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4)."),
|
|
539
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-status media as external URLs (max 4)."),
|
|
540
|
+
})).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."),
|
|
541
|
+
}).optional().describe("Mastodon options"),
|
|
411
542
|
google_business: z.object({
|
|
412
543
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
413
544
|
cta: z.object({
|
|
@@ -493,6 +624,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
493
624
|
made_for_kids: z.boolean().optional(),
|
|
494
625
|
notify_subscribers: z.boolean().optional(),
|
|
495
626
|
contains_synthetic_media: z.boolean().optional(),
|
|
627
|
+
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. Pass an empty string to clear a previously set first comment."),
|
|
496
628
|
}).optional().describe("YouTube Shorts options. Only applies when type is 'reel' and youtube is among the selected channels."),
|
|
497
629
|
pinterest: z.object({
|
|
498
630
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
@@ -506,7 +638,11 @@ Do NOT call without required media — it will fail.`, {
|
|
|
506
638
|
thumbnail_type: z.enum(["from-video", "from-library"]).optional(),
|
|
507
639
|
thumb_offset: z.number().optional(),
|
|
508
640
|
cover_url: z.string().optional(),
|
|
509
|
-
|
|
641
|
+
first_comment: z.string().max(2200).optional().describe("Text auto-posted as the first comment on the post/reel right after it publishes. Pass an empty string to clear a previously set first comment."),
|
|
642
|
+
}).optional().describe("Instagram options"),
|
|
643
|
+
facebook: FACEBOOK_OPTIONS,
|
|
644
|
+
linkedin: LINKEDIN_OPTIONS,
|
|
645
|
+
linkedin_page: LINKEDIN_PAGE_OPTIONS,
|
|
510
646
|
tiktok: z.object({
|
|
511
647
|
privacy_level: z.enum(["PUBLIC_TO_EVERYONE", "MUTUAL_FOLLOW_FRIENDS", "FOLLOWER_OF_CREATOR", "SELF_ONLY"]).optional(),
|
|
512
648
|
disable_comment: z.boolean().optional(),
|
|
@@ -526,6 +662,20 @@ Do NOT call without required media — it will fail.`, {
|
|
|
526
662
|
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
|
|
527
663
|
})).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."),
|
|
528
664
|
}).optional().describe("X (Twitter) options"),
|
|
665
|
+
bluesky: z.object({
|
|
666
|
+
thread_parts: z.array(z.object({
|
|
667
|
+
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
668
|
+
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."),
|
|
669
|
+
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."),
|
|
670
|
+
})).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."),
|
|
671
|
+
}).optional().describe("Bluesky options"),
|
|
672
|
+
mastodon: z.object({
|
|
673
|
+
thread_parts: z.array(z.object({
|
|
674
|
+
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
675
|
+
media_ids: z.array(z.string()).max(4).optional().describe("Per-status media as Library IDs from upload_media (max 4)."),
|
|
676
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-status media as external URLs (max 4)."),
|
|
677
|
+
})).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."),
|
|
678
|
+
}).optional().describe("Mastodon options"),
|
|
529
679
|
google_business: z.object({
|
|
530
680
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
531
681
|
cta: z.object({
|
|
@@ -625,4 +775,56 @@ Notes:
|
|
|
625
775
|
});
|
|
626
776
|
return { content: [{ type: "text", text: md }] };
|
|
627
777
|
});
|
|
778
|
+
server.tool("get_recent_platform_posts", "Fetch the user's most recent posts straight from their connected platform APIs (Instagram, TikTok, X, YouTube, Facebook, LinkedIn, and more), INCLUDING content published outside OmniSocials. Use this when list_posts is empty — e.g. a brand-new workspace that has not published through OmniSocials yet — so you can still analyze the user's real content. Each post includes normalized `engagement` and `impressions` so you can rank across platforms. Metrics only appear where the platform exposes them for historical posts (X, TikTok, Bluesky, Mastodon, Instagram, Facebook, YouTube); LinkedIn, Threads, Pinterest, and Google Business return captions only. Fetched live, so expect a few seconds of latency. Requires the analytics:read scope.", {
|
|
779
|
+
limit: z
|
|
780
|
+
.string()
|
|
781
|
+
.optional()
|
|
782
|
+
.describe("Max posts per connected platform (1-50, default 25)."),
|
|
783
|
+
platforms: z
|
|
784
|
+
.string()
|
|
785
|
+
.optional()
|
|
786
|
+
.describe('Optional comma-separated platform filter, e.g. "instagram,tiktok". Defaults to every connected platform.'),
|
|
787
|
+
}, async (params) => {
|
|
788
|
+
const result = await getClient().getRecentPlatformPosts(params);
|
|
789
|
+
if (result.error) {
|
|
790
|
+
return {
|
|
791
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
const posts = result.data || [];
|
|
795
|
+
const connected = result.connected_platforms || [];
|
|
796
|
+
const errors = result.errors || {};
|
|
797
|
+
const errEntries = Object.entries(errors);
|
|
798
|
+
const clean = (s) => String(s || "").replace(/[|\n\r]+/g, " ").trim();
|
|
799
|
+
if (!posts.length) {
|
|
800
|
+
const note = result.note || "No recent platform posts found.";
|
|
801
|
+
const errLines = errEntries.map(([p, m]) => `- ${capitalize(p)}: ${m}`).join("\n");
|
|
802
|
+
return {
|
|
803
|
+
content: [{ type: "text", text: `${note}${errLines ? `\n\nFetch errors:\n${errLines}` : ""}` }],
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
let md = `## Recent platform posts (${posts.length} across ${connected.length} platform${connected.length === 1 ? "" : "s"})\n\n`;
|
|
807
|
+
md += `| # | Platform | Format | Content | Engagement | Impressions | Date |\n`;
|
|
808
|
+
md += `|---|----------|--------|---------|------------|-------------|------|\n`;
|
|
809
|
+
posts.forEach((p, i) => {
|
|
810
|
+
const hasMetrics = p.metrics && Object.keys(p.metrics).length > 0;
|
|
811
|
+
const content = truncate(clean(p.text), 45) || "*(no caption)*";
|
|
812
|
+
const eng = hasMetrics ? formatNumber(p.engagement || 0) : "—";
|
|
813
|
+
const imp = hasMetrics ? formatNumber(p.impressions || 0) : "—";
|
|
814
|
+
const date = p.timestamp ? formatDateTime(p.timestamp) : "—";
|
|
815
|
+
md += `| ${i + 1} | ${capitalize(p.platform)} | ${p.format || "post"} | ${content} | ${eng} | ${imp} | ${date} |\n`;
|
|
816
|
+
});
|
|
817
|
+
if (result.note)
|
|
818
|
+
md += `\n${result.note}\n`;
|
|
819
|
+
if (errEntries.length) {
|
|
820
|
+
md += `\n**Fetch errors:**\n`;
|
|
821
|
+
errEntries.forEach(([pl, m]) => {
|
|
822
|
+
md += `- ${capitalize(pl)}: ${m}\n`;
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
md += `\nPosts showing "—" for engagement have no metrics available from that platform for historical posts; analyze their captions and formats instead.\n`;
|
|
826
|
+
return {
|
|
827
|
+
content: [{ type: "text", text: md }],
|
|
828
|
+
};
|
|
829
|
+
});
|
|
628
830
|
}
|
package/build/types.d.ts
CHANGED
|
@@ -37,11 +37,32 @@ export interface Post {
|
|
|
37
37
|
export interface MediaItem {
|
|
38
38
|
id: string;
|
|
39
39
|
url: string;
|
|
40
|
+
thumbnail_url?: string | null;
|
|
40
41
|
type: string;
|
|
42
|
+
name?: string | null;
|
|
41
43
|
filename: string;
|
|
42
|
-
|
|
44
|
+
folder_id?: string | null;
|
|
45
|
+
size: string;
|
|
46
|
+
status?: string;
|
|
43
47
|
created_at: string;
|
|
44
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Extra top-level fields returned by /media/upload* when the uploaded file is a
|
|
51
|
+
* PDF. A PDF is rasterized into one image slide per page (max 20): `data`
|
|
52
|
+
* mirrors the first slide (back-compat), while `slides` + `media_ids` carry the
|
|
53
|
+
* whole carousel in page order. Pass ALL of `media_ids` to create_post. On
|
|
54
|
+
* LinkedIn the slides post as a native swipeable document; elsewhere as an
|
|
55
|
+
* image carousel.
|
|
56
|
+
*/
|
|
57
|
+
export interface PdfUploadResult {
|
|
58
|
+
slides: MediaItem[];
|
|
59
|
+
media_ids: string[];
|
|
60
|
+
pdf: {
|
|
61
|
+
total_pages: number;
|
|
62
|
+
rendered_pages: number;
|
|
63
|
+
truncated: boolean;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
45
66
|
export interface Account {
|
|
46
67
|
id: string;
|
|
47
68
|
platform: string;
|
|
@@ -88,12 +109,14 @@ export interface AnalyticsOverview {
|
|
|
88
109
|
total_engagements: number;
|
|
89
110
|
period: string;
|
|
90
111
|
}
|
|
112
|
+
export interface PostAnalyticsPlatformEntry {
|
|
113
|
+
platform: string;
|
|
114
|
+
platform_post_id: string;
|
|
115
|
+
metrics: Record<string, number | string>;
|
|
116
|
+
thread_parts: number;
|
|
117
|
+
collected_at: string;
|
|
118
|
+
}
|
|
91
119
|
export interface PostAnalytics {
|
|
92
120
|
post_id: string;
|
|
93
|
-
|
|
94
|
-
engagements: number;
|
|
95
|
-
likes: number;
|
|
96
|
-
comments: number;
|
|
97
|
-
shares: number;
|
|
98
|
-
platform_stats: Record<string, unknown>;
|
|
121
|
+
platforms: Record<string, PostAnalyticsPlatformEntry>;
|
|
99
122
|
}
|
package/package.json
CHANGED