@omnisocials/mcp-server 1.50.0 → 1.51.1
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 +15 -0
- package/build/client.d.ts +30 -1
- package/build/client.js +50 -1
- package/build/index.js +12 -2
- package/build/tools/analytics.js +1 -0
- package/build/tools/hashtag-sets.js +2 -1
- package/build/tools/inbox.js +3 -3
- package/build/tools/posts.js +17 -0
- package/build/tools/tags.d.ts +3 -0
- package/build/tools/tags.js +102 -0
- package/build/types.d.ts +48 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -157,6 +157,17 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
|
|
|
157
157
|
| `update_hashtag_set` | Rename a set and/or replace its tags |
|
|
158
158
|
| `delete_hashtag_set` | Delete a set (existing posts keep their hashtags) |
|
|
159
159
|
|
|
160
|
+
### Tags (6 tools)
|
|
161
|
+
|
|
162
|
+
| Tool | Description |
|
|
163
|
+
|------|-------------|
|
|
164
|
+
| `list_tags` | List the workspace's post tags |
|
|
165
|
+
| `create_tag` | Create a tag ("Question hook", "Talking head") |
|
|
166
|
+
| `update_tag` | Rename or recolour a tag |
|
|
167
|
+
| `delete_tag` | Delete a tag (it comes off every post; the posts stay) |
|
|
168
|
+
| `tag_posts` | Add, remove or replace the tags of up to 500 posts at once, published posts included |
|
|
169
|
+
| `get_tag_analytics` | Compare tags: posts, median impressions, engagement rate and how far above or below your normal, per channel |
|
|
170
|
+
|
|
160
171
|
Apply a set when creating a post: `create_post` / `create_and_publish_post` accept `hashtag_set` (the set name), `hashtag_placement` (`caption_append` default, or `first_comment` to keep tags out of the caption where supported), and `hashtag_platforms` (subset of channels).
|
|
161
172
|
|
|
162
173
|
### Accounts (2 tools)
|
|
@@ -245,6 +256,10 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
245
256
|
|
|
246
257
|
## Changelog
|
|
247
258
|
|
|
259
|
+
### 1.51.0 (2026-11-02)
|
|
260
|
+
|
|
261
|
+
- **Added: post tags.** `list_tags`, `create_tag`, `update_tag`, `delete_tag`, `tag_posts` and `get_tag_analytics` (how each tag did against your normal, per channel). `tags` on `create_post`, `create_and_publish_post` and `update_post`, `tag` on `list_posts` and `get_analytics_overview`. `get_post` and `list_posts` show tags and mark Instagram trial reels.
|
|
262
|
+
|
|
248
263
|
### 1.50.0 (2026-10-27)
|
|
249
264
|
|
|
250
265
|
- **Added: `like_inbox_comment`.** Like (or unlike with `like: false`) a Facebook, LinkedIn Page or TikTok comment as the connected account. Facebook allows a Like only. Inbox messages carry `liked`.
|
package/build/client.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, Automation, AutomationEvent, AutomationEventOutcome, AutomationEventsResult, AutomationInput, AutomationPlatform, BestTimesResult, CommentDmAutomationInput, HashtagSet, LocationSearchResult, PinterestProductCheck, PinterestProductsResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, PostApproval, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
|
|
1
|
+
import type { Account, AnalyticsOverview, AccountAnalyticsEntry, ApiPost, ApiResponse, ApiVideoCover, AudioSearchResult, Automation, AutomationEvent, AutomationEventOutcome, AutomationEventsResult, AutomationInput, AutomationPlatform, BestTimesResult, CommentDmAutomationInput, HashtagSet, Tag, TagAnalytics, TagApplyResult, LocationSearchResult, PinterestProductCheck, PinterestProductsResult, MediaCheckResult, MediaCompatibility, MediaFolder, MediaItem, PdfUploadResult, PostAnalytics, PublishPostResult, RecentPlatformPost, RetryPostResult, ApprovePostResult, RejectPostResult, PostApproval, InboxConversation, InboxDeleteResult, InboxMessage, InboxNextUnanswered, Webhook } from "./types.js";
|
|
2
2
|
export declare function fetchImageAsBase64(url: string): Promise<{
|
|
3
3
|
data: string;
|
|
4
4
|
mimeType: string;
|
|
@@ -8,6 +8,8 @@ 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
10
|
export declare function truncate(value: unknown, len: number): string;
|
|
11
|
+
export declare function cutText(value: unknown, max: number): string;
|
|
12
|
+
export declare function wellFormed<T>(value: T): T;
|
|
11
13
|
export declare function capitalize(str: string): string;
|
|
12
14
|
/** Rates, averages and lengths: shown per platform, never added up across
|
|
13
15
|
* platforms (two 60% hold rates are not 120%). Mirrors
|
|
@@ -355,6 +357,7 @@ export declare class OmniSocialsClient {
|
|
|
355
357
|
status?: string;
|
|
356
358
|
limit?: string;
|
|
357
359
|
offset?: string;
|
|
360
|
+
tag?: string;
|
|
358
361
|
}): Promise<ApiResponse<ApiPost[]>>;
|
|
359
362
|
getPost(id: string): Promise<ApiResponse<ApiPost>>;
|
|
360
363
|
getRecentPlatformPosts(params?: {
|
|
@@ -476,6 +479,31 @@ export declare class OmniSocialsClient {
|
|
|
476
479
|
parent_id?: string;
|
|
477
480
|
}): Promise<ApiResponse<MediaFolder>>;
|
|
478
481
|
listApprovalWorkflows(): Promise<ApiResponse<ApprovalWorkflow[]>>;
|
|
482
|
+
listTags(): Promise<ApiResponse<Tag[]>>;
|
|
483
|
+
createTag(data: {
|
|
484
|
+
name: string;
|
|
485
|
+
color?: string;
|
|
486
|
+
}): Promise<ApiResponse<Tag>>;
|
|
487
|
+
updateTag(id: string, data: {
|
|
488
|
+
name?: string;
|
|
489
|
+
color?: string;
|
|
490
|
+
}): Promise<ApiResponse<Tag>>;
|
|
491
|
+
deleteTag(id: string): Promise<ApiResponse<{
|
|
492
|
+
tag: Tag;
|
|
493
|
+
posts_untagged: number;
|
|
494
|
+
}>>;
|
|
495
|
+
applyTags(data: {
|
|
496
|
+
post_ids: string[];
|
|
497
|
+
add?: string[];
|
|
498
|
+
remove?: string[];
|
|
499
|
+
set?: string[];
|
|
500
|
+
}): Promise<ApiResponse<TagApplyResult>>;
|
|
501
|
+
getTagAnalytics(params?: {
|
|
502
|
+
start_date?: string;
|
|
503
|
+
end_date?: string;
|
|
504
|
+
platform?: string;
|
|
505
|
+
min_posts?: string;
|
|
506
|
+
}): Promise<ApiResponse<TagAnalytics>>;
|
|
479
507
|
listHashtagSets(): Promise<ApiResponse<HashtagSet[]>>;
|
|
480
508
|
createHashtagSet(data: {
|
|
481
509
|
name: string;
|
|
@@ -512,6 +540,7 @@ export declare class OmniSocialsClient {
|
|
|
512
540
|
period?: string;
|
|
513
541
|
start_date?: string;
|
|
514
542
|
end_date?: string;
|
|
543
|
+
tag?: string;
|
|
515
544
|
}): Promise<ApiResponse<AnalyticsOverview> & {
|
|
516
545
|
period?: string;
|
|
517
546
|
start_date?: string;
|
package/build/client.js
CHANGED
|
@@ -84,7 +84,37 @@ export function truncate(value, len) {
|
|
|
84
84
|
if (!str)
|
|
85
85
|
return "";
|
|
86
86
|
const clean = str.replace(/\n/g, " ");
|
|
87
|
-
|
|
87
|
+
// Count and cut on code points: a UTF-16 slice can split an emoji.
|
|
88
|
+
const chars = Array.from(clean);
|
|
89
|
+
return chars.length > len ? chars.slice(0, len - 1).join("") + "…" : clean;
|
|
90
|
+
}
|
|
91
|
+
// Text this server sends must be well-formed UTF-16. Half an emoji (a lone
|
|
92
|
+
// surrogate) is legal in a JS string and JSON.stringify writes it as
|
|
93
|
+
// "\ud83c", but strict JSON readers (Rust serde_json, Python clients that
|
|
94
|
+
// validate with pydantic) refuse it and drop the connection: "Transport
|
|
95
|
+
// closed" instead of an answer (2026-11-03, a drafts list cut a preview
|
|
96
|
+
// through an emoji). cutText cuts on code points; wellFormed turns any lone
|
|
97
|
+
// surrogate left in a tool result into U+FFFD (a regex: Node 18 has no
|
|
98
|
+
// String.prototype.toWellFormed).
|
|
99
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
100
|
+
export function cutText(value, max) {
|
|
101
|
+
if (typeof value !== "string")
|
|
102
|
+
return "";
|
|
103
|
+
const chars = Array.from(value);
|
|
104
|
+
return chars.length > max ? chars.slice(0, max).join("") : value;
|
|
105
|
+
}
|
|
106
|
+
export function wellFormed(value) {
|
|
107
|
+
if (typeof value === "string")
|
|
108
|
+
return value.replace(LONE_SURROGATE, "\uFFFD");
|
|
109
|
+
if (Array.isArray(value))
|
|
110
|
+
return value.map(wellFormed);
|
|
111
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
|
|
112
|
+
const out = {};
|
|
113
|
+
for (const [key, item] of Object.entries(value))
|
|
114
|
+
out[key] = wellFormed(item);
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
return value;
|
|
88
118
|
}
|
|
89
119
|
export function capitalize(str) {
|
|
90
120
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
@@ -466,6 +496,25 @@ export class OmniSocialsClient {
|
|
|
466
496
|
return this.request("GET", "/approval-workflows");
|
|
467
497
|
}
|
|
468
498
|
// Hashtag Sets
|
|
499
|
+
// Post tags (2026-11-02)
|
|
500
|
+
async listTags() {
|
|
501
|
+
return this.request("GET", "/tags");
|
|
502
|
+
}
|
|
503
|
+
async createTag(data) {
|
|
504
|
+
return this.request("POST", "/tags", data);
|
|
505
|
+
}
|
|
506
|
+
async updateTag(id, data) {
|
|
507
|
+
return this.request("PATCH", `/tags/${id}`, data);
|
|
508
|
+
}
|
|
509
|
+
async deleteTag(id) {
|
|
510
|
+
return this.request("DELETE", `/tags/${id}`);
|
|
511
|
+
}
|
|
512
|
+
async applyTags(data) {
|
|
513
|
+
return this.request("POST", "/tags/apply", data);
|
|
514
|
+
}
|
|
515
|
+
async getTagAnalytics(params) {
|
|
516
|
+
return this.request("GET", "/analytics/tags", undefined, params);
|
|
517
|
+
}
|
|
469
518
|
async listHashtagSets() {
|
|
470
519
|
return this.request("GET", "/hashtag-sets");
|
|
471
520
|
}
|
package/build/index.js
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { OmniSocialsClient } from "./client.js";
|
|
5
|
+
import { OmniSocialsClient, wellFormed } from "./client.js";
|
|
6
6
|
import { registerPostTools } from "./tools/posts.js";
|
|
7
7
|
import { registerMediaTools } from "./tools/media.js";
|
|
8
8
|
import { registerAccountTools } from "./tools/accounts.js";
|
|
9
9
|
import { registerAnalyticsTools } from "./tools/analytics.js";
|
|
10
10
|
import { registerWebhookTools } from "./tools/webhooks.js";
|
|
11
11
|
import { registerHashtagSetTools } from "./tools/hashtag-sets.js";
|
|
12
|
+
import { registerTagTools } from "./tools/tags.js";
|
|
12
13
|
import { registerAutomationTools } from "./tools/automations.js";
|
|
13
14
|
import { registerInboxTools } from "./tools/inbox.js";
|
|
14
15
|
import { registerWorkspaceTools } from "./tools/workspaces.js";
|
|
@@ -30,8 +31,16 @@ const sessionState = { activeIndex: 0 };
|
|
|
30
31
|
const getActiveClient = () => workspaceClients[sessionState.activeIndex].client;
|
|
31
32
|
const server = new McpServer({
|
|
32
33
|
name: "OmniSocials",
|
|
33
|
-
version: "1.
|
|
34
|
+
version: "1.51.1",
|
|
34
35
|
});
|
|
36
|
+
const rawTool = server.tool.bind(server);
|
|
37
|
+
server.tool = (...args) => {
|
|
38
|
+
const cb = args[args.length - 1];
|
|
39
|
+
if (typeof cb === "function") {
|
|
40
|
+
args[args.length - 1] = async (...a) => wellFormed(await cb(...a));
|
|
41
|
+
}
|
|
42
|
+
return rawTool(...args);
|
|
43
|
+
};
|
|
35
44
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
36
45
|
registerPostTools(server, getActiveClient);
|
|
37
46
|
registerMediaTools(server, getActiveClient);
|
|
@@ -39,6 +48,7 @@ registerAccountTools(server, getActiveClient);
|
|
|
39
48
|
registerAnalyticsTools(server, getActiveClient);
|
|
40
49
|
registerWebhookTools(server, getActiveClient);
|
|
41
50
|
registerHashtagSetTools(server, getActiveClient);
|
|
51
|
+
registerTagTools(server, getActiveClient);
|
|
42
52
|
registerAutomationTools(server, getActiveClient);
|
|
43
53
|
registerInboxTools(server, getActiveClient);
|
|
44
54
|
registerWorkspaceTools(server, workspaceClients, sessionState);
|
package/build/tools/analytics.js
CHANGED
|
@@ -181,6 +181,7 @@ export function registerAnalyticsTools(server, getClient) {
|
|
|
181
181
|
period: z.string().optional().describe("Rolling window: 7d, 30d, 90d (default: 30d). Ignored if start_date/end_date are provided."),
|
|
182
182
|
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).'),
|
|
183
183
|
end_date: z.string().optional().describe('Custom end date. Same formats as start_date; "YYYY-MM" expands to the last day of the month.'),
|
|
184
|
+
tag: z.string().optional().describe("Post numbers of the posts with this tag only (a tag name or id; comma separate several). Account numbers stay the whole account's. To compare tags with each other, use get_tag_analytics."),
|
|
184
185
|
}, { title: "Get Analytics Overview", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
185
186
|
const result = await getClient().getAnalyticsOverview(params);
|
|
186
187
|
if (result.error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { cutText } from "../client.js";
|
|
2
3
|
export function registerHashtagSetTools(server, getClient) {
|
|
3
4
|
server.tool("list_hashtag_sets", "List the saved hashtag sets in this workspace. Apply one to a new post by passing its name via create_post (hashtag_set) — the tags are appended to the captions or, with hashtag_placement='first_comment', posted as the auto first comment.", {}, { title: "List Hashtag Sets", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
4
5
|
const result = await getClient().listHashtagSets();
|
|
@@ -17,7 +18,7 @@ export function registerHashtagSetTools(server, getClient) {
|
|
|
17
18
|
md += `| Set ID | Name | Tags | Preview |\n`;
|
|
18
19
|
md += `|--------|------|------|---------|\n`;
|
|
19
20
|
for (const s of sets) {
|
|
20
|
-
const preview = (s.preview || "").length > 80 ? `${s.preview
|
|
21
|
+
const preview = Array.from(s.preview || "").length > 80 ? `${cutText(s.preview, 77)}…` : s.preview || "";
|
|
21
22
|
md += `| \`${s.id}\` | ${s.name} | ${s.hashtag_count} | ${preview} |\n`;
|
|
22
23
|
}
|
|
23
24
|
md += `\nApply a set with create_post (hashtag_set="<name>"). Edit with update_hashtag_set.`;
|
package/build/tools/inbox.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { formatDateTime } from "../client.js";
|
|
2
|
+
import { cutText, formatDateTime } from "../client.js";
|
|
3
3
|
const PLATFORM_EMOJI = {
|
|
4
4
|
instagram: "📸",
|
|
5
5
|
facebook: "📘",
|
|
@@ -41,7 +41,7 @@ function renderInboxPost(post) {
|
|
|
41
41
|
}
|
|
42
42
|
md += `\n`;
|
|
43
43
|
if (post.caption) {
|
|
44
|
-
md += `> ${post.caption.replace(/\s*\n+\s*/g, " ")
|
|
44
|
+
md += `> ${cutText(post.caption.replace(/\s*\n+\s*/g, " "), 600)}\n`;
|
|
45
45
|
}
|
|
46
46
|
if (post.url)
|
|
47
47
|
md += `🔗 ${post.url}\n`;
|
|
@@ -148,7 +148,7 @@ export function registerInboxTools(server, getClient) {
|
|
|
148
148
|
md += `|----------|------|------|--------|----------------|----------------|\n`;
|
|
149
149
|
for (const c of convos) {
|
|
150
150
|
const who = c.participant?.name || c.participant?.username || "Unknown";
|
|
151
|
-
const text = (c.last_message?.text || "").replace(/\n/g, " ")
|
|
151
|
+
const text = cutText((c.last_message?.text || "").replace(/\n/g, " "), 40);
|
|
152
152
|
md += `| ${PLATFORM_EMOJI[c.platform] || ""} ${c.platform} | ${c.type} | ${who} | ${c.unread_count || 0} | ${text} | \`${c.conversation_id}\` |\n`;
|
|
153
153
|
}
|
|
154
154
|
if (result.pagination?.next_cursor) {
|
package/build/tools/posts.js
CHANGED
|
@@ -22,6 +22,10 @@ const COMMENT_DM_AUTOMATION = z.object({
|
|
|
22
22
|
stop_at: z.string().nullable().optional().describe("Stop at this time (ISO 8601)."),
|
|
23
23
|
}).optional(),
|
|
24
24
|
}).describe("Comment-to-DM automation on this post: when somebody comments one of trigger.keywords on the post once it is live on Instagram or the Facebook Page, the account DMs them actions.dm.text once (on Facebook the DM lands in Messenger), and optionally posts one of actions.public_reply.variations under the comment. Instagram or Facebook must be in channels; platforms picks which ones get it. Show the user the keywords and the exact DM text before creating it. The created automations come back as comment_dm_automation on the post (automations lists the id of each channel); manage them later with update_automation / get_automation_activity. Needs the opt-in automations:write scope on the API key.");
|
|
25
|
+
// Post tags (2026-11-02): the workspace's own labels to compare posts.
|
|
26
|
+
const POST_TAGS = z.array(z.string()).max(20).describe("Tags for this post: tag names or ids, e.g. [\"Question hook\", \"Talking head\"]; a name that does not exist yet becomes a new tag. Tags are the workspace's own labels (a hook style, a format, a campaign) to filter and compare posts in Analytics (get_tag_analytics); nothing goes to a platform. Use list_tags to see the existing ones and reuse their exact names.");
|
|
27
|
+
const tagLabel = (t) => t.name;
|
|
28
|
+
const tagsLine = (p) => Array.isArray(p.tags) && p.tags.length ? `| **Tags** | ${p.tags.map(tagLabel).join(", ")} |\n` : "";
|
|
25
29
|
const commentDmLine = (p) => {
|
|
26
30
|
const a = p.comment_dm_automation;
|
|
27
31
|
if (!a)
|
|
@@ -457,6 +461,7 @@ export function registerPostTools(server, getClient) {
|
|
|
457
461
|
status: z.string().optional().describe("Filter by status: draft, in_approval, scheduled, posting, published, failed, warning. in_approval = waiting for a reviewer in an approval workflow."),
|
|
458
462
|
limit: z.string().optional().describe("Max results to return (default: 20)"),
|
|
459
463
|
offset: z.string().optional().describe("Offset for pagination"),
|
|
464
|
+
tag: z.string().optional().describe("Only posts with this tag: a tag name or id; comma separate several for any of them. See list_tags."),
|
|
460
465
|
}, { title: "List Posts", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
461
466
|
const result = await getClient().listPosts(params);
|
|
462
467
|
if (result.error) {
|
|
@@ -510,6 +515,10 @@ export function registerPostTools(server, getClient) {
|
|
|
510
515
|
const channels = selectedChannels(p).join(", ") || "—";
|
|
511
516
|
const status = capitalize(p.status || "—");
|
|
512
517
|
const date = formatDateTime(displayDate(p));
|
|
518
|
+
if (Array.isArray(p.tags) && p.tags.length)
|
|
519
|
+
content += ` *[${p.tags.map(tagLabel).join(", ")}]*`;
|
|
520
|
+
if (p.trial_reel)
|
|
521
|
+
content += " *(IG trial reel)*";
|
|
513
522
|
md += `| ${i + 1} | ${content} | ${channels} | ${status} | ${date} | \`${p.id}\` |\n`;
|
|
514
523
|
}
|
|
515
524
|
return {
|
|
@@ -936,6 +945,9 @@ export function registerPostTools(server, getClient) {
|
|
|
936
945
|
// Trial Reel state: nested under the instagram options object by the
|
|
937
946
|
// API. Only rendered when enabled, so batch-scheduled Trial Reels can be
|
|
938
947
|
// verified without opening the dashboard.
|
|
948
|
+
if (Array.isArray(p.tags) && p.tags.length) {
|
|
949
|
+
md += `\n\n**Tags:** ${p.tags.map(tagLabel).join(", ")}\n`;
|
|
950
|
+
}
|
|
939
951
|
const igOpts = p.instagram;
|
|
940
952
|
if (igOpts && typeof igOpts === "object" && igOpts.is_trial_reel === true) {
|
|
941
953
|
const strategy = igOpts.trial_graduation_strategy === "SS_PERFORMANCE"
|
|
@@ -1093,6 +1105,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
1093
1105
|
discord: DISCORD_OPTIONS,
|
|
1094
1106
|
approval_workflow_id: z.string().optional().describe("Route the post through a saved approval workflow (id from list_approval_workflows). The post is created as `in_approval` instead of `scheduled`: its approvers are notified, they review it on the Approvals page, and it publishes at scheduled_at only once the workflow's last step approves it (or approve_post). Use this whenever the user wants to review/approve agent-created posts before they go out. Requires scheduled_at; not allowed with publish_now (use create_post, never create_and_publish_post). A workflow with no approvers on its first step is rejected with validation_error."),
|
|
1095
1107
|
comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
|
|
1108
|
+
tags: POST_TAGS.optional(),
|
|
1096
1109
|
}, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
1097
1110
|
const result = await getClient().createPost({ ...params, source: "mcp" });
|
|
1098
1111
|
if (result.error) {
|
|
@@ -1135,6 +1148,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
1135
1148
|
if (channels.length)
|
|
1136
1149
|
md += `| **Channels** | ${channels.join(", ")} |\n`;
|
|
1137
1150
|
md += commentDmLine(p);
|
|
1151
|
+
md += tagsLine(p);
|
|
1138
1152
|
if (appUrl)
|
|
1139
1153
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
1140
1154
|
md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
|
|
@@ -1269,6 +1283,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1269
1283
|
telegram: TELEGRAM_OPTIONS,
|
|
1270
1284
|
discord: DISCORD_OPTIONS,
|
|
1271
1285
|
comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
|
|
1286
|
+
tags: POST_TAGS.optional(),
|
|
1272
1287
|
}, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async (params) => {
|
|
1273
1288
|
const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
|
|
1274
1289
|
if (result.error) {
|
|
@@ -1393,6 +1408,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1393
1408
|
telegram: TELEGRAM_OPTIONS,
|
|
1394
1409
|
discord: DISCORD_OPTIONS,
|
|
1395
1410
|
comment_dm_automation: COMMENT_DM_AUTOMATION.nullable().optional().describe("Replaces the post's comment-to-DM setup (keywords, DM text, public replies, limits, platforms); a channel no longer listed in platforms loses its automation; pass null to remove them all; omit to leave them untouched. Needs the opt-in automations:write scope."),
|
|
1411
|
+
tags: POST_TAGS.nullable().optional().describe("Replaces the post's tags (names or ids; a new name becomes a tag); null or [] removes them all. An update with only tags works on every post, published ones included. To add or remove a tag on many posts at once, use tag_posts."),
|
|
1396
1412
|
}, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
|
|
1397
1413
|
const result = await getClient().updatePost(id, data);
|
|
1398
1414
|
if (result.error) {
|
|
@@ -1415,6 +1431,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1415
1431
|
md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
|
|
1416
1432
|
if (schedAt)
|
|
1417
1433
|
md += `| **${p.status === "draft" ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
|
|
1434
|
+
md += tagsLine(p);
|
|
1418
1435
|
if (appUrl)
|
|
1419
1436
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
1420
1437
|
if (p.status === "draft" && schedAt) {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { capitalize, formatNumber } from "../client.js";
|
|
3
|
+
// Post tags (2026-11-02): the workspace's own labels for its posts, to filter
|
|
4
|
+
// and compare them. Mirrors the hosted server (backend/routes/api/v1/mcp.js).
|
|
5
|
+
const COLORS = ["blue", "green", "orange", "red", "purple", "pink", "teal", "yellow", "brown", "gray"];
|
|
6
|
+
const tagLabel = (t) => t.name;
|
|
7
|
+
const fmtVsNormal = (v) => v === null || v === undefined ? "—" : `${v >= 0 ? "+" : "−"}${Math.round(Math.abs(v) * 100)}%`;
|
|
8
|
+
const errorText = (error) => ({
|
|
9
|
+
content: [{ type: "text", text: `Error (${error.code}): ${error.message}` }],
|
|
10
|
+
});
|
|
11
|
+
const text = (md) => ({ content: [{ type: "text", text: md }] });
|
|
12
|
+
export function registerTagTools(server, getClient) {
|
|
13
|
+
server.tool("list_tags", "List the workspace's post tags (the team's own labels for its posts: a hook style, a format, a campaign). Tags filter list_posts (tag) and get_analytics_overview (tag), and get_tag_analytics compares them. Reuse these exact names when tagging so no near-duplicates appear.", {}, { title: "List Tags", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async () => {
|
|
14
|
+
const result = await getClient().listTags();
|
|
15
|
+
if (result.error)
|
|
16
|
+
return errorText(result.error);
|
|
17
|
+
const items = Array.isArray(result.data) ? result.data : [];
|
|
18
|
+
if (!items.length) {
|
|
19
|
+
return text("No tags yet. Create one with create_tag, or pass tags on create_post / update_post / tag_posts (a new name becomes a tag).");
|
|
20
|
+
}
|
|
21
|
+
let md = `## Tags (${items.length})\n\n| Tag ID | Name | Colour |\n|--------|------|--------|\n`;
|
|
22
|
+
for (const t of items)
|
|
23
|
+
md += `| \`${t.id}\` | ${t.name} | ${t.color} |\n`;
|
|
24
|
+
md += `\nTag posts with tag_posts or tags on create_post / update_post. Compare tags with get_tag_analytics.`;
|
|
25
|
+
return text(md);
|
|
26
|
+
});
|
|
27
|
+
server.tool("create_tag", "Create a post tag, e.g. 'Question hook', 'Talking head' or 'Spring campaign'. A name is unique in the workspace (case-insensitive). At most 200 tags per workspace. Tags can also be made on the fly: a new name in tags on create_post / update_post or in tag_posts becomes a tag.", {
|
|
28
|
+
name: z.string().max(40).describe("Tag name, e.g. 'Question hook'"),
|
|
29
|
+
color: z.enum(COLORS).optional().describe("Colour in the app; omit for the next colour of the palette"),
|
|
30
|
+
}, { title: "Create Tag", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
31
|
+
const result = await getClient().createTag(params);
|
|
32
|
+
if (result.error)
|
|
33
|
+
return errorText(result.error);
|
|
34
|
+
const t = result.data;
|
|
35
|
+
return text(`Tag \`${t.id}\` created: ${tagLabel(t)} (${t.color}).`);
|
|
36
|
+
});
|
|
37
|
+
server.tool("update_tag", "Rename or recolour a post tag. Posts keep the tag.", {
|
|
38
|
+
id: z.string().describe("Tag id (from list_tags)"),
|
|
39
|
+
name: z.string().max(40).optional().describe("New name"),
|
|
40
|
+
color: z.enum(COLORS).optional(),
|
|
41
|
+
}, { title: "Update Tag", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
|
|
42
|
+
const result = await getClient().updateTag(id, data);
|
|
43
|
+
if (result.error)
|
|
44
|
+
return errorText(result.error);
|
|
45
|
+
const t = result.data;
|
|
46
|
+
return text(`Tag \`${t.id}\` updated: ${tagLabel(t)} (${t.color}).`);
|
|
47
|
+
});
|
|
48
|
+
server.tool("delete_tag", "Delete a post tag. It comes off every post of the workspace; the posts stay. Cannot be undone: confirm with the user first.", { id: z.string().describe("Tag id (from list_tags)") }, { title: "Delete Tag", readOnlyHint: false, destructiveHint: true, openWorldHint: false }, async ({ id }) => {
|
|
49
|
+
const result = await getClient().deleteTag(id);
|
|
50
|
+
if (result.error)
|
|
51
|
+
return errorText(result.error);
|
|
52
|
+
const d = result.data;
|
|
53
|
+
return text(`Tag "${d?.tag ? tagLabel(d.tag) : id}" deleted; it came off ${d?.posts_untagged ?? 0} post(s).`);
|
|
54
|
+
});
|
|
55
|
+
server.tool("tag_posts", "Add, remove or replace the tags of up to 500 posts at once, published posts included (tags never go to a platform). Use it to tag older posts so get_tag_analytics has enough posts to compare: find them with list_posts, read their captions, then tag them. add / set take tag names or ids (a new name becomes a tag); remove takes names or ids; set replaces a post's tags.", {
|
|
56
|
+
post_ids: z.array(z.string()).min(1).max(500).describe("OmniSocials post ids (from list_posts)"),
|
|
57
|
+
add: z.array(z.string()).optional().describe('Tags to add, e.g. ["Question hook"]'),
|
|
58
|
+
remove: z.array(z.string()).optional().describe("Tags to remove"),
|
|
59
|
+
set: z.array(z.string()).optional().describe("Replace the posts' tags with exactly these; [] removes all"),
|
|
60
|
+
}, { title: "Tag Posts", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
61
|
+
const result = await getClient().applyTags(params);
|
|
62
|
+
if (result.error)
|
|
63
|
+
return errorText(result.error);
|
|
64
|
+
const d = result.data;
|
|
65
|
+
let md = `## Posts tagged (${d?.updated ?? 0} changed)\n\n| Post ID | Tags now |\n|---------|----------|\n`;
|
|
66
|
+
for (const p of d?.posts || [])
|
|
67
|
+
md += `| \`${p.id}\` | ${(p.tags || []).map(tagLabel).join(", ") || "—"} |\n`;
|
|
68
|
+
if (d?.not_found?.length)
|
|
69
|
+
md += `\nNot posts of this workspace: ${d.not_found.map((x) => `\`${x}\``).join(", ")}.`;
|
|
70
|
+
return text(md);
|
|
71
|
+
});
|
|
72
|
+
server.tool("get_tag_analytics", "Compare post tags: for each tag, how its published posts did against the user's normal in a period (the Tags tab of Analytics). vs normal is fair across channels: each post is measured against the median impressions of its own channel, so a tag does not win just because its posts went to the channel with the most views. A tag with fewer than min_posts measured posts is marked 'too few posts': say so instead of drawing a conclusion. Default period: the last 90 days.", {
|
|
73
|
+
start_date: z.string().optional().describe("YYYY-MM-DD or YYYY-MM (default: 90 days ago)"),
|
|
74
|
+
end_date: z.string().optional().describe("YYYY-MM-DD or YYYY-MM (default: today)"),
|
|
75
|
+
platform: z.string().optional().describe("Only one channel's numbers, e.g. tiktok"),
|
|
76
|
+
min_posts: z.string().optional().describe("Posts a tag needs before it counts as comparable (default 5)"),
|
|
77
|
+
}, { title: "Get Tag Analytics", readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
78
|
+
const result = await getClient().getTagAnalytics(params);
|
|
79
|
+
if (result.error)
|
|
80
|
+
return errorText(result.error);
|
|
81
|
+
const d = result.data;
|
|
82
|
+
if (!d)
|
|
83
|
+
return text("No tag data returned.");
|
|
84
|
+
const period = d.period ? `${String(d.period.start_date).slice(0, 10)} → ${String(d.period.end_date).slice(0, 10)}` : "";
|
|
85
|
+
let md = `## Tags compared (${period}${d.platform ? `, ${capitalize(d.platform)}` : ", all channels"})\n\n`;
|
|
86
|
+
if (!d.tags?.length) {
|
|
87
|
+
md += "No tags yet. Tag posts with tag_posts first.\n\n";
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
md += `| Tag | Posts | Median impressions | Eng. rate | vs normal | Best on |\n|-----|-------|--------------------|-----------|-----------|---------|\n`;
|
|
91
|
+
for (const r of d.tags) {
|
|
92
|
+
const vs = r.enough_posts ? fmtVsNormal(r.vs_normal) : `too few posts (${fmtVsNormal(r.vs_normal)})`;
|
|
93
|
+
const median = r.median_impressions === null ? "—" : formatNumber(Math.round(r.median_impressions));
|
|
94
|
+
const rate = r.engagement_rate === null ? "—" : `${r.engagement_rate.toFixed(1)}%`;
|
|
95
|
+
md += `| ${r.tag.name} | ${r.posts} | ${median} | ${rate} | ${vs} | ${r.best_channel ? capitalize(r.best_channel) : "—"} |\n`;
|
|
96
|
+
}
|
|
97
|
+
md += "\n";
|
|
98
|
+
}
|
|
99
|
+
md += `Posts without a tag: ${d.untagged?.posts ?? 0}. A tag needs ${d.min_posts ?? 5} measured posts before vs normal counts.`;
|
|
100
|
+
return text(md);
|
|
101
|
+
});
|
|
102
|
+
}
|
package/build/types.d.ts
CHANGED
|
@@ -195,8 +195,56 @@ export interface ApiStorySlide {
|
|
|
195
195
|
media_type?: "image" | "video" | string | null;
|
|
196
196
|
published_at?: string | null;
|
|
197
197
|
}
|
|
198
|
+
/** A workspace's own label for its posts (GET /tags, 2026-11-02). */
|
|
199
|
+
export interface Tag {
|
|
200
|
+
id: number;
|
|
201
|
+
name: string;
|
|
202
|
+
color: string;
|
|
203
|
+
created_at?: string;
|
|
204
|
+
}
|
|
205
|
+
export interface TagSummary {
|
|
206
|
+
posts: number;
|
|
207
|
+
measured: number;
|
|
208
|
+
median_impressions: number | null;
|
|
209
|
+
/** Percent (5.4 = 5.4%). */
|
|
210
|
+
engagement_rate: number | null;
|
|
211
|
+
/** Fraction against the person's normal (0.38 = 38% above). */
|
|
212
|
+
vs_normal: number | null;
|
|
213
|
+
best_channel: string | null;
|
|
214
|
+
enough_posts: boolean;
|
|
215
|
+
}
|
|
216
|
+
/** GET /analytics/tags */
|
|
217
|
+
export interface TagAnalytics {
|
|
218
|
+
period: {
|
|
219
|
+
start_date: string;
|
|
220
|
+
end_date: string;
|
|
221
|
+
};
|
|
222
|
+
platform: string | null;
|
|
223
|
+
min_posts: number;
|
|
224
|
+
/** Comparable tags first, best first. */
|
|
225
|
+
tags: (TagSummary & {
|
|
226
|
+
tag: Tag;
|
|
227
|
+
})[];
|
|
228
|
+
untagged: TagSummary;
|
|
229
|
+
normals: Record<string, number | null>;
|
|
230
|
+
}
|
|
231
|
+
/** POST /tags/apply */
|
|
232
|
+
export interface TagApplyResult {
|
|
233
|
+
updated: number;
|
|
234
|
+
posts: {
|
|
235
|
+
id: string;
|
|
236
|
+
tags: Tag[];
|
|
237
|
+
}[];
|
|
238
|
+
not_found: string[];
|
|
239
|
+
}
|
|
198
240
|
export interface ApiPost {
|
|
199
241
|
id: string;
|
|
242
|
+
/** The post's tags (2026-11-02). */
|
|
243
|
+
tags?: Tag[];
|
|
244
|
+
/** Set when the post is an Instagram Trial Reel. */
|
|
245
|
+
trial_reel?: {
|
|
246
|
+
strategy: "MANUAL" | "SS_PERFORMANCE";
|
|
247
|
+
} | null;
|
|
200
248
|
/** DB "posted" is mapped to "published" by the serializer. */
|
|
201
249
|
status: string;
|
|
202
250
|
/** "post" | "story" | "reel" */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnisocials/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.1",
|
|
4
4
|
"mcpName": "io.github.omnisocials/mcp-server",
|
|
5
5
|
"description": "MCP server for OmniSocials API - manage social media posts, media, accounts, analytics, and webhooks",
|
|
6
6
|
"type": "module",
|