@omnisocials/mcp-server 1.7.1 → 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/README.md CHANGED
@@ -146,11 +146,12 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
146
146
  | `list_accounts` | List connected social media accounts |
147
147
  | `get_account` | Get account details |
148
148
 
149
- ### Analytics (3 tools)
149
+ ### Analytics (4 tools)
150
150
 
151
151
  | Tool | Description |
152
152
  |------|-------------|
153
153
  | `get_post_analytics` | Get stats for a published post |
154
+ | `get_posts_analytics` | Get stats for up to 100 posts in one call (bulk) |
154
155
  | `get_analytics_overview` | Overview analytics for a time period |
155
156
  | `get_account_analytics` | Account-level analytics (followers, etc.) |
156
157
 
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<unknown>>;
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<unknown>>;
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`,
@@ -165,6 +228,7 @@ export declare class OmniSocialsClient {
165
228
  listAccounts(): Promise<ApiResponse<unknown>>;
166
229
  getAccount(id: string): Promise<ApiResponse<unknown>>;
167
230
  getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
231
+ getPostsAnalytics(postIds: string[]): Promise<ApiResponse<unknown>>;
168
232
  getAnalyticsOverview(params?: {
169
233
  period?: string;
170
234
  start_date?: string;
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) {
@@ -202,6 +211,13 @@ export class OmniSocialsClient {
202
211
  async getPostAnalytics(postId) {
203
212
  return this.request("GET", `/analytics/posts/${postId}`);
204
213
  }
214
+ // Batch version of getPostAnalytics — fetch up to 100 posts' latest
215
+ // per-platform metrics in one call instead of one request per post.
216
+ async getPostsAnalytics(postIds) {
217
+ return this.request("GET", "/analytics/posts", undefined, {
218
+ ids: postIds.join(","),
219
+ });
220
+ }
205
221
  async getAnalyticsOverview(params) {
206
222
  return this.request("GET", "/analytics/overview", undefined, params);
207
223
  }
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.7.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);
@@ -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
- // Aggregate the per-platform metrics into totals; the legacy flat shape
16
- // (d.impressions / d.platform_stats) doesn't exist on this endpoint and
17
- // reading those keys returned "—" for every field.
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) {
@@ -65,6 +66,60 @@ export function registerAnalyticsTools(server, getClient) {
65
66
  content: [{ type: "text", text: md }],
66
67
  };
67
68
  });
69
+ server.tool("get_posts_analytics", "Get analytics for several published posts at once (up to 100). Returns each post's totalled impressions and engagements. Use this instead of calling get_post_analytics in a loop — it is one request rather than one per post.", {
70
+ post_ids: z
71
+ .array(z.string())
72
+ .min(1)
73
+ .max(100)
74
+ .describe("Numeric OmniSocials post ids (the `id` field from list_posts), up to 100. NOT platform post ids such as YouTube video ids or tweet ids."),
75
+ }, async ({ post_ids }) => {
76
+ const result = await getClient().getPostsAnalytics(post_ids);
77
+ if (result.error) {
78
+ return {
79
+ content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
80
+ };
81
+ }
82
+ // API returns { data: [{ post_id, platforms: { <platform>: { metrics } } }] }.
83
+ // Per-platform metrics are already thread-summed server-side; collapse each
84
+ // post's per-platform metrics into totals, mirroring get_post_analytics.
85
+ const entries = result.data || [];
86
+ const rows = entries.map((entry) => {
87
+ const platforms = entry?.platforms || {};
88
+ const platformEntries = Object.entries(platforms);
89
+ let impressions = 0;
90
+ let engagements = 0;
91
+ for (const [platform, p] of platformEntries) {
92
+ const m = p?.metrics || {};
93
+ const imp = platform === "instagram"
94
+ ? Number(m.reach ?? m.impressions ?? 0)
95
+ : Number(m.views ?? m.impressions ?? 0);
96
+ const likes = Number(m.likes ?? m.favorites ?? m.reactions ?? 0);
97
+ const comments = Number(m.comments ?? m.replies ?? 0);
98
+ const shares = Number(m.shares ?? m.retweets ?? m.reposts ?? 0);
99
+ const eng = m.engagement != null ? Number(m.engagement) : likes + comments + shares;
100
+ impressions += imp;
101
+ engagements += eng;
102
+ }
103
+ return {
104
+ post_id: entry.post_id,
105
+ platformCount: platformEntries.length,
106
+ platformNames: platformEntries.map(([name]) => capitalize(name)).join(", "),
107
+ impressions,
108
+ engagements,
109
+ };
110
+ });
111
+ const withData = rows.filter((r) => r.platformCount > 0).length;
112
+ let md = `## Posts Analytics (${rows.length} posts, ${withData} with collected stats)\n\n`;
113
+ md += `| Post ID | Platforms | Impressions | Engagements |\n`;
114
+ md += `|---------|-----------|-------------|-------------|\n`;
115
+ for (const r of rows) {
116
+ md += `| ${r.post_id} | ${r.platformNames || "—"} | ${r.platformCount ? formatNumber(r.impressions) : "—"} | ${r.platformCount ? formatNumber(r.engagements) : "—"} |\n`;
117
+ }
118
+ md += `\nPosts showing "—" have no analytics collected yet. Stats are fetched periodically after publishing; check back in a few hours.\n`;
119
+ return {
120
+ content: [{ type: "text", text: md }],
121
+ };
122
+ });
68
123
  server.tool("get_analytics_overview", "Get analytics overview with total posts, impressions, engagements, engagement rate, and per-platform breakdown. Use `period` for a rolling window (7d / 30d / 90d) or `start_date` + `end_date` for a custom range. The response echoes the resolved range and today's date — read those before assuming a year from training data (e.g. when the user says 'April', use the most recent April, not April from your training cutoff).", {
69
124
  period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
70
125
  start_date: z.string().optional().describe('Custom start date. Accepts "YYYY-MM-DD" (e.g. "2026-04-01") or "YYYY-MM" month-shorthand (e.g. "2026-04" → first of the month).'),
@@ -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`;
@@ -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
- }).optional().describe("Instagram Reel options"),
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(),
@@ -271,11 +367,25 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
271
367
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
272
368
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
273
369
  thread_parts: z.array(z.object({
274
- text: z.string().describe("Tweet text (≤ 280 chars)"),
370
+ 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."),
275
371
  media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
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"),
@@ -403,11 +520,25 @@ Do NOT call without required media — it will fail.`, {
403
520
  paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
404
521
  made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
405
522
  thread_parts: z.array(z.object({
406
- text: z.string().describe("Tweet text (≤ 280 chars)"),
523
+ 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."),
407
524
  media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
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
- }).optional().describe("Instagram Reel options"),
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(),
@@ -521,11 +657,25 @@ Do NOT call without required media — it will fail.`, {
521
657
  paid_partnership: z.boolean().optional(),
522
658
  made_with_ai: z.boolean().optional(),
523
659
  thread_parts: z.array(z.object({
524
- text: z.string().describe("Tweet text (≤ 280 chars)"),
660
+ 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."),
525
661
  media_ids: z.array(z.string()).max(4).optional().describe("Per-tweet media as Library IDs from upload_media (max 4 combined with media_urls). Attach your uploaded graphics to any tweet in the thread."),
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
  }
@@ -73,6 +73,10 @@ export function registerWebhookTools(server, getClient) {
73
73
  md += `| **Status** | ${w.is_active ? "Active" : "Inactive"} |\n`;
74
74
  md += `| **Last Triggered** | ${w.last_triggered_at ? formatDateTime(w.last_triggered_at) : "Never"} |\n`;
75
75
  md += `| **Failure Count** | ${w.failure_count ?? 0} |\n`;
76
+ if (w.last_failure) {
77
+ const lf = w.last_failure;
78
+ md += `| **Last Failure** | ${formatDateTime(lf.at)} — ${lf.error} |\n`;
79
+ }
76
80
  md += `| **Created** | ${formatDateTime(w.created_at)} |\n`;
77
81
  return {
78
82
  content: [{ type: "text", text: md }],
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
- size: number;
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;
@@ -74,6 +95,12 @@ export interface Webhook {
74
95
  is_active: boolean;
75
96
  last_triggered_at: string | null;
76
97
  failure_count: number;
98
+ last_failure: {
99
+ at: string;
100
+ status: number | null;
101
+ error: string;
102
+ attempts: number;
103
+ } | null;
77
104
  created_at: string;
78
105
  }
79
106
  export interface AnalyticsOverview {
@@ -82,12 +109,14 @@ export interface AnalyticsOverview {
82
109
  total_engagements: number;
83
110
  period: string;
84
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
+ }
85
119
  export interface PostAnalytics {
86
120
  post_id: string;
87
- impressions: number;
88
- engagements: number;
89
- likes: number;
90
- comments: number;
91
- shares: number;
92
- platform_stats: Record<string, unknown>;
121
+ platforms: Record<string, PostAnalyticsPlatformEntry>;
93
122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omnisocials/mcp-server",
3
- "version": "1.7.1",
3
+ "version": "1.9.0",
4
4
  "description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
5
5
  "type": "module",
6
6
  "main": "build/index.js",