@omnisocials/mcp-server 1.5.0 → 1.7.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 +7 -4
- package/build/client.d.ts +47 -0
- package/build/client.js +21 -0
- package/build/index.js +1 -1
- package/build/tools/analytics.js +1 -1
- package/build/tools/media.js +137 -17
- package/build/tools/posts.js +26 -2
- package/build/tools/workspaces.js +88 -38
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -128,13 +128,16 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
|
|
|
128
128
|
| `publish_post` | Publish a draft or scheduled post now |
|
|
129
129
|
| `search_locations` | Find an Instagram location to tag (returns place IDs for `location_id`) |
|
|
130
130
|
|
|
131
|
-
### Media (
|
|
131
|
+
### Media & folders (6 tools)
|
|
132
132
|
|
|
133
133
|
| Tool | Description |
|
|
134
134
|
|------|-------------|
|
|
135
|
-
| `list_media` | List
|
|
136
|
-
| `upload_media` | Upload media from a URL (max 50MB) |
|
|
135
|
+
| `list_media` | List media files; filter by `search` (name) or `folder_id` |
|
|
136
|
+
| `upload_media` | Upload media from a URL (max 50MB); set a `name` and a `folder` |
|
|
137
|
+
| `update_media` | Rename a media file or move it to a folder |
|
|
137
138
|
| `delete_media` | Delete a media file |
|
|
139
|
+
| `list_folders` | List the workspace's media folders |
|
|
140
|
+
| `create_folder` | Create a media folder (optionally nested) |
|
|
138
141
|
|
|
139
142
|
### Accounts (2 tools)
|
|
140
143
|
|
|
@@ -156,7 +159,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
|
|
|
156
159
|
| Tool | Description |
|
|
157
160
|
|------|-------------|
|
|
158
161
|
| `list_workspaces` | List all workspaces available in this session |
|
|
159
|
-
| `switch_workspace` | Switch the active workspace |
|
|
162
|
+
| `switch_workspace` | Switch the active workspace **by name** (id or list number also accepted) |
|
|
160
163
|
|
|
161
164
|
### Webhooks (5 tools)
|
|
162
165
|
|
package/build/client.d.ts
CHANGED
|
@@ -56,6 +56,13 @@ export declare class OmniSocialsClient {
|
|
|
56
56
|
link_description?: string;
|
|
57
57
|
link_thumbnail_url?: string;
|
|
58
58
|
location_id?: string;
|
|
59
|
+
collaborators?: string[];
|
|
60
|
+
user_tags?: Array<{
|
|
61
|
+
username: string;
|
|
62
|
+
x: number;
|
|
63
|
+
y: number;
|
|
64
|
+
image_index?: number;
|
|
65
|
+
}>;
|
|
59
66
|
pinterest?: Record<string, unknown>;
|
|
60
67
|
youtube?: Record<string, unknown>;
|
|
61
68
|
instagram?: Record<string, unknown>;
|
|
@@ -75,6 +82,13 @@ export declare class OmniSocialsClient {
|
|
|
75
82
|
link_description?: string;
|
|
76
83
|
link_thumbnail_url?: string;
|
|
77
84
|
location_id?: string;
|
|
85
|
+
collaborators?: string[];
|
|
86
|
+
user_tags?: Array<{
|
|
87
|
+
username: string;
|
|
88
|
+
x: number;
|
|
89
|
+
y: number;
|
|
90
|
+
image_index?: number;
|
|
91
|
+
}>;
|
|
78
92
|
pinterest?: Record<string, unknown>;
|
|
79
93
|
youtube?: Record<string, unknown>;
|
|
80
94
|
instagram?: Record<string, unknown>;
|
|
@@ -90,6 +104,13 @@ export declare class OmniSocialsClient {
|
|
|
90
104
|
media_urls?: string[] | Record<string, string[]>;
|
|
91
105
|
type?: string;
|
|
92
106
|
location_id?: string;
|
|
107
|
+
collaborators?: string[];
|
|
108
|
+
user_tags?: Array<{
|
|
109
|
+
username: string;
|
|
110
|
+
x: number;
|
|
111
|
+
y: number;
|
|
112
|
+
image_index?: number;
|
|
113
|
+
}>;
|
|
93
114
|
pinterest?: Record<string, unknown>;
|
|
94
115
|
youtube?: Record<string, unknown>;
|
|
95
116
|
instagram?: Record<string, unknown>;
|
|
@@ -104,17 +125,43 @@ export declare class OmniSocialsClient {
|
|
|
104
125
|
listMedia(params?: {
|
|
105
126
|
limit?: string;
|
|
106
127
|
offset?: string;
|
|
128
|
+
search?: string;
|
|
129
|
+
folder_id?: string;
|
|
107
130
|
}): Promise<ApiResponse<unknown>>;
|
|
108
131
|
uploadMedia(data: {
|
|
109
132
|
url: string;
|
|
110
133
|
filename?: string;
|
|
134
|
+
name?: string;
|
|
135
|
+
folder?: string;
|
|
111
136
|
}): Promise<ApiResponse<unknown>>;
|
|
112
137
|
uploadMediaFromBase64(data: {
|
|
113
138
|
data: string;
|
|
114
139
|
mime_type: string;
|
|
115
140
|
filename?: string;
|
|
141
|
+
name?: string;
|
|
142
|
+
folder?: string;
|
|
143
|
+
}): Promise<ApiResponse<unknown>>;
|
|
144
|
+
/**
|
|
145
|
+
* Preflight a file's compatibility with the workspace's connected platforms
|
|
146
|
+
* BEFORE uploading. Provide one of: a public `url`, an existing `media_id`,
|
|
147
|
+
* or `size_bytes` + `mime`.
|
|
148
|
+
*/
|
|
149
|
+
checkMediaCompatibility(data: {
|
|
150
|
+
url?: string;
|
|
151
|
+
media_id?: string;
|
|
152
|
+
size_bytes?: number;
|
|
153
|
+
mime?: string;
|
|
116
154
|
}): Promise<ApiResponse<unknown>>;
|
|
117
155
|
deleteMedia(id: string): Promise<ApiResponse<unknown>>;
|
|
156
|
+
updateMedia(id: string, data: {
|
|
157
|
+
name?: string;
|
|
158
|
+
folder_id?: string | null;
|
|
159
|
+
}): Promise<ApiResponse<unknown>>;
|
|
160
|
+
listFolders(): Promise<ApiResponse<unknown>>;
|
|
161
|
+
createFolder(data: {
|
|
162
|
+
name: string;
|
|
163
|
+
parent_id?: string;
|
|
164
|
+
}): Promise<ApiResponse<unknown>>;
|
|
118
165
|
listAccounts(): Promise<ApiResponse<unknown>>;
|
|
119
166
|
getAccount(id: string): Promise<ApiResponse<unknown>>;
|
|
120
167
|
getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
|
package/build/client.js
CHANGED
|
@@ -162,14 +162,35 @@ export class OmniSocialsClient {
|
|
|
162
162
|
return this.request("GET", "/media", undefined, params);
|
|
163
163
|
}
|
|
164
164
|
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.
|
|
165
168
|
return this.request("POST", "/media/upload-from-url", data);
|
|
166
169
|
}
|
|
167
170
|
async uploadMediaFromBase64(data) {
|
|
168
171
|
return this.request("POST", "/media/upload-from-base64", data);
|
|
169
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* Preflight a file's compatibility with the workspace's connected platforms
|
|
175
|
+
* BEFORE uploading. Provide one of: a public `url`, an existing `media_id`,
|
|
176
|
+
* or `size_bytes` + `mime`.
|
|
177
|
+
*/
|
|
178
|
+
async checkMediaCompatibility(data) {
|
|
179
|
+
return this.request("POST", "/media/check", data);
|
|
180
|
+
}
|
|
170
181
|
async deleteMedia(id) {
|
|
171
182
|
return this.request("DELETE", `/media/${id}`);
|
|
172
183
|
}
|
|
184
|
+
async updateMedia(id, data) {
|
|
185
|
+
return this.request("PATCH", `/media/${id}`, data);
|
|
186
|
+
}
|
|
187
|
+
// Folders
|
|
188
|
+
async listFolders() {
|
|
189
|
+
return this.request("GET", "/folders");
|
|
190
|
+
}
|
|
191
|
+
async createFolder(data) {
|
|
192
|
+
return this.request("POST", "/folders", data);
|
|
193
|
+
}
|
|
173
194
|
// Accounts
|
|
174
195
|
async listAccounts() {
|
|
175
196
|
return this.request("GET", "/accounts");
|
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.7.1",
|
|
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
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { formatNumber, capitalize } from "../client.js";
|
|
3
3
|
export function registerAnalyticsTools(server, getClient) {
|
|
4
4
|
server.tool("get_post_analytics", "Get analytics/statistics for a specific published post (impressions, engagements, likes, etc.).", {
|
|
5
|
-
post_id: z.string().describe("The post
|
|
5
|
+
post_id: z.string().describe("The numeric OmniSocials post id (the `id` field from list_posts). NOT a platform post id such as a YouTube video id or tweet id."),
|
|
6
6
|
}, async ({ post_id }) => {
|
|
7
7
|
const result = await getClient().getPostAnalytics(post_id);
|
|
8
8
|
if (result.error) {
|
package/build/tools/media.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { fetchImageAsBase64, formatBytes } from "../client.js";
|
|
3
3
|
export function registerMediaTools(server, getClient) {
|
|
4
|
-
server.tool("list_media", "List
|
|
4
|
+
server.tool("list_media", "List media files in the workspace. Returns each file's ID, name, folder, type (image/video), and size. To reuse an existing graphic, SEARCH for it by name here FIRST instead of re-uploading — re-uploading the same image creates duplicates. Media IDs from this list can be passed to create_post. Organize assets into folders with create_folder / list_folders.", {
|
|
5
5
|
limit: z.string().optional().describe("Max results to return"),
|
|
6
6
|
offset: z.string().optional().describe("Offset for pagination"),
|
|
7
|
+
search: z.string().optional().describe('Find a file by name or filename, e.g. "play5get50". Use this before uploading to check if the graphic already exists.'),
|
|
8
|
+
folder_id: z.string().optional().describe('Only return media in this folder id (from list_folders). Use "root" for unfiled items.'),
|
|
7
9
|
}, async (params) => {
|
|
8
10
|
const result = await getClient().listMedia(params);
|
|
9
11
|
if (result.error) {
|
|
@@ -13,22 +15,26 @@ export function registerMediaTools(server, getClient) {
|
|
|
13
15
|
}
|
|
14
16
|
const items = Array.isArray(result.data) ? result.data : result.data?.media || [];
|
|
15
17
|
if (!items.length) {
|
|
18
|
+
const hint = params?.search
|
|
19
|
+
? `No media found matching "${params.search}". It hasn't been uploaded yet — upload it (and pass a \`name\`) so it's findable next time.`
|
|
20
|
+
: "No media files found.";
|
|
16
21
|
return {
|
|
17
|
-
content: [{ type: "text", text:
|
|
22
|
+
content: [{ type: "text", text: hint }],
|
|
18
23
|
};
|
|
19
24
|
}
|
|
20
25
|
let md = `## Media Library (${items.length} files)\n\n`;
|
|
21
|
-
md += `| # | Type |
|
|
22
|
-
md +=
|
|
26
|
+
md += `| # | Type | Name | Folder | Size | Media ID |\n`;
|
|
27
|
+
md += `|---|------|------|--------|------|----------|\n`;
|
|
23
28
|
for (let i = 0; i < items.length; i++) {
|
|
24
29
|
const m = items[i];
|
|
25
30
|
// `type` may be "video"/"image" (normalized) or a raw MIME like "video/mp4".
|
|
26
31
|
const rawType = typeof m.type === "string" ? m.type : "";
|
|
27
32
|
const isVideo = rawType === "video" || rawType.startsWith("video/");
|
|
28
33
|
const type = isVideo ? "Video" : "Image";
|
|
29
|
-
|
|
34
|
+
const folder = m.folder_id ? `\`${m.folder_id}\`` : "—";
|
|
35
|
+
md += `| ${i + 1} | ${type} | ${m.name || m.filename || "—"} | ${folder} | ${(m.size || "—")} | \`${m.id}\` |\n`;
|
|
30
36
|
}
|
|
31
|
-
md += `\nUse the **Media ID** with \`media_ids\` when creating posts
|
|
37
|
+
md += `\nUse the **Media ID** with \`media_ids\` when creating posts. Rename or move a file with \`update_media\`. See folders with \`list_folders\`.`;
|
|
32
38
|
// Fetch thumbnails for image files (max 4)
|
|
33
39
|
const content = [];
|
|
34
40
|
const imageItems = items.filter((m) => {
|
|
@@ -49,13 +55,21 @@ export function registerMediaTools(server, getClient) {
|
|
|
49
55
|
content.push({ type: "text", text: md });
|
|
50
56
|
return { content };
|
|
51
57
|
});
|
|
52
|
-
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).
|
|
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.
|
|
59
|
+
|
|
60
|
+
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
|
+
|
|
62
|
+
Large videos (over 100 MB) are processed in the background: the response comes back with status "processing" — the file is NOT usable in a post until its status is "ready". Re-check with list_media (search by the name you gave it) until its status is "ready".
|
|
63
|
+
|
|
64
|
+
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.
|
|
53
65
|
|
|
54
66
|
When the user provides an image in the conversation (not a URL), use base64_data + mime_type to upload it directly.`, {
|
|
55
67
|
url: z.string().optional().describe("Public URL of the media file to upload. Use this OR base64_data, not both."),
|
|
56
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."),
|
|
57
69
|
mime_type: z.string().optional().describe("MIME type of the file (e.g. 'image/jpeg', 'image/png'). Required when using base64_data."),
|
|
58
70
|
filename: z.string().optional().describe("Optional filename for the uploaded media"),
|
|
71
|
+
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
|
+
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.'),
|
|
59
73
|
}, async (params) => {
|
|
60
74
|
let result;
|
|
61
75
|
if (params.base64_data && params.mime_type) {
|
|
@@ -63,10 +77,12 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
63
77
|
data: params.base64_data,
|
|
64
78
|
mime_type: params.mime_type,
|
|
65
79
|
filename: params.filename,
|
|
80
|
+
name: params.name || params.filename,
|
|
81
|
+
folder: params.folder,
|
|
66
82
|
});
|
|
67
83
|
}
|
|
68
84
|
else if (params.url) {
|
|
69
|
-
result = await getClient().uploadMedia({ url: params.url, filename: params.filename });
|
|
85
|
+
result = await getClient().uploadMedia({ url: params.url, filename: params.filename, name: params.name || params.filename, folder: params.folder });
|
|
70
86
|
}
|
|
71
87
|
else {
|
|
72
88
|
return {
|
|
@@ -79,26 +95,130 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
79
95
|
};
|
|
80
96
|
}
|
|
81
97
|
const m = result.data;
|
|
82
|
-
|
|
98
|
+
const compatibility = result.compatibility;
|
|
99
|
+
const isProcessing = m.status === "processing";
|
|
100
|
+
let md = isProcessing ? `## Media Processing\n\n` : `## Media Uploaded\n\n`;
|
|
83
101
|
md += `| Field | Value |\n`;
|
|
84
102
|
md += `|-------|-------|\n`;
|
|
85
103
|
md += `| **Media ID** | \`${m.id}\` |\n`;
|
|
104
|
+
md += `| **Name** | ${m.name || m.filename || "—"} |\n`;
|
|
86
105
|
md += `| **Type** | ${m.type || "—"} |\n`;
|
|
87
|
-
|
|
88
|
-
|
|
106
|
+
if (m.status)
|
|
107
|
+
md += `| **Status** | ${m.status} |\n`;
|
|
108
|
+
if (m.folder_id)
|
|
109
|
+
md += `| **Folder** | \`${m.folder_id}\` |\n`;
|
|
110
|
+
md += `| **Size** | ${(m.size || "—")} |\n`;
|
|
89
111
|
if (m.url)
|
|
90
112
|
md += `| **URL** | ${m.url} |\n`;
|
|
91
|
-
|
|
113
|
+
if (isProcessing) {
|
|
114
|
+
md += `\n⏳ This large video is still being processed. Re-check with list_media (search by its name) until its status is **ready** before using it in a post.`;
|
|
115
|
+
}
|
|
116
|
+
if (compatibility && compatibility.compatible === false && compatibility.summary) {
|
|
117
|
+
md += `\n\n⚠️ **${compatibility.summary}** It will still post to your other connected platforms. Ask the user whether to continue before adding it to a post.`;
|
|
118
|
+
}
|
|
119
|
+
md += `\n\nUse this Media ID with \`media_ids\` when creating posts (including inside \`x.thread_parts[].media_ids\`). The public URL above also works anywhere \`media_urls\` is accepted.`;
|
|
92
120
|
return {
|
|
93
121
|
content: [{ type: "text", text: md }],
|
|
94
122
|
};
|
|
95
123
|
});
|
|
96
|
-
server.tool("
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
server.tool("check_media_compatibility", `Check whether a video/image will be accepted by the platforms CONNECTED to this workspace, BEFORE uploading or posting. Use this when the user gives you a file (URL) so you can warn them upfront — e.g. "this 995 MB video won't post to Instagram (max 300 MB), continue?" — and confirm before uploading.
|
|
125
|
+
|
|
126
|
+
Provide ONE of: a public 'url' (its size/type is read via a HEAD request), an existing 'media_id', or explicit 'size_bytes' + 'mime'. Returns the connected platforms and any that would reject the file by size or format.`, {
|
|
127
|
+
url: z.string().optional().describe("Public URL of the file to check. Use this OR media_id OR size_bytes+mime."),
|
|
128
|
+
media_id: z.string().optional().describe("Id of an already-uploaded library item to check."),
|
|
129
|
+
size_bytes: z.number().optional().describe("File size in bytes (use with mime when you already know them)."),
|
|
130
|
+
mime: z.string().optional().describe("MIME type, e.g. 'video/mp4' (use with size_bytes)."),
|
|
131
|
+
}, async (params) => {
|
|
132
|
+
if (!params.url && !params.media_id && !(params.size_bytes && params.mime)) {
|
|
133
|
+
return {
|
|
134
|
+
content: [{ type: "text", text: "Error: Provide one of 'url', 'media_id', or 'size_bytes' + 'mime'." }],
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const result = await getClient().checkMediaCompatibility(params);
|
|
138
|
+
if (result.error) {
|
|
139
|
+
return {
|
|
140
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const r = result;
|
|
144
|
+
const connected = r.connected_platforms || [];
|
|
145
|
+
let md = `## Compatibility Check\n\n`;
|
|
146
|
+
if (r.file) {
|
|
147
|
+
const sz = r.file.size_known ? formatBytes(r.file.size_bytes) : "unknown size";
|
|
148
|
+
md += `File: ${sz}${r.file.mime ? ` · ${r.file.mime}` : ""}\n`;
|
|
149
|
+
}
|
|
150
|
+
md += `Connected platforms: ${connected.length ? connected.join(", ") : "none"}\n\n`;
|
|
151
|
+
if (r.compatible) {
|
|
152
|
+
md += `✅ This file posts to all your connected platforms.`;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
md += `⚠️ ${r.summary || "Some connected platforms would reject this file:"}\n\n`;
|
|
156
|
+
md += `| Platform | Why |\n|----------|-----|\n`;
|
|
157
|
+
for (const w of r.warnings || []) {
|
|
158
|
+
md += `| ${w.display_name} | ${(w.reasons || []).join(", ")} |\n`;
|
|
159
|
+
}
|
|
160
|
+
md += `\nAsk the user whether to continue. It will still post to the platforms not listed above.`;
|
|
161
|
+
}
|
|
162
|
+
return { content: [{ type: "text", text: md }] };
|
|
163
|
+
});
|
|
164
|
+
server.tool("update_media", "Rename an existing media file or move it into a folder (so it's findable later instead of re-uploading it). Only the fields you pass are changed.", {
|
|
165
|
+
id: z.string().describe("The media ID to update"),
|
|
166
|
+
name: z.string().optional().describe('New human-readable label, e.g. "pp-play5get50". Pass an empty string to clear it.'),
|
|
167
|
+
folder_id: z.string().optional().describe('Move the file into this folder id (from list_folders). Pass an empty string to move it to the root ("All media").'),
|
|
168
|
+
}, async ({ id, name, folder_id }) => {
|
|
169
|
+
const body = {};
|
|
170
|
+
if (name !== undefined)
|
|
171
|
+
body.name = name;
|
|
172
|
+
if (folder_id !== undefined)
|
|
173
|
+
body.folder_id = folder_id === "" ? null : folder_id;
|
|
174
|
+
const result = await getClient().updateMedia(id, body);
|
|
175
|
+
if (result.error) {
|
|
176
|
+
return {
|
|
177
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const m = result.data;
|
|
181
|
+
return {
|
|
182
|
+
content: [
|
|
183
|
+
{
|
|
184
|
+
type: "text",
|
|
185
|
+
text: `Updated media \`${m.id}\`.\n\n| Field | Value |\n|-------|-------|\n| **Name** | ${m.name || "—"} |\n| **Folder** | ${m.folder_id ? `\`${m.folder_id}\`` : "root"} |`,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
server.tool("list_folders", "List the media-library folders in this workspace (Finder-style organization). Returns each folder's id, name, parent, and item count. Use a folder id with list_media (folder_id) or update_media (folder_id), or a folder name with upload_media (folder).", {}, async () => {
|
|
191
|
+
const result = await getClient().listFolders();
|
|
192
|
+
if (result.error) {
|
|
193
|
+
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
194
|
+
}
|
|
195
|
+
const items = Array.isArray(result.data) ? result.data : result.data?.data || [];
|
|
196
|
+
if (!items.length) {
|
|
197
|
+
return { content: [{ type: "text", text: "No folders yet. Create one with create_folder." }] };
|
|
198
|
+
}
|
|
199
|
+
let md = `## Folders (${items.length})\n\n`;
|
|
200
|
+
md += `| Folder ID | Name | Parent | Items |\n|-----------|------|--------|-------|\n`;
|
|
201
|
+
for (const f of items) {
|
|
202
|
+
md += `| \`${f.id}\` | ${f.name} | ${f.parent_id ? `\`${f.parent_id}\`` : "—"} | ${f.item_count ?? 0} |\n`;
|
|
203
|
+
}
|
|
204
|
+
return { content: [{ type: "text", text: md }] };
|
|
205
|
+
});
|
|
206
|
+
server.tool("create_folder", "Create a media-library folder. Use it to organize assets (e.g. a 'win-graphics' folder), then upload into it with upload_media (folder) or move files with update_media (folder_id).", {
|
|
207
|
+
name: z.string().describe("Folder name, e.g. 'win-graphics'"),
|
|
208
|
+
parent_id: z.string().optional().describe("Optional parent folder id to nest under (from list_folders). Omit for a top-level folder."),
|
|
209
|
+
}, async ({ name, parent_id }) => {
|
|
210
|
+
const result = await getClient().createFolder({ name, parent_id });
|
|
211
|
+
if (result.error) {
|
|
212
|
+
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
213
|
+
}
|
|
214
|
+
const f = result.data;
|
|
100
215
|
return {
|
|
101
|
-
content: [
|
|
216
|
+
content: [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: `Created folder \`${f.id}\` "${f.name}". Upload into it with upload_media (folder="${f.name}").`,
|
|
220
|
+
},
|
|
221
|
+
],
|
|
102
222
|
};
|
|
103
223
|
});
|
|
104
224
|
}
|
package/build/tools/posts.js
CHANGED
|
@@ -180,7 +180,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
180
180
|
3. **Schedule**: When should it be published? (Or save as draft?)
|
|
181
181
|
4. **Media** (REQUIRED for some types):
|
|
182
182
|
- Stories: ALWAYS require an image or video. Ask the user which image/video to use. Use list_media to show their uploaded media, or ask for an external URL.
|
|
183
|
-
- Reels: ALWAYS require a video. Ask the user which video to use
|
|
183
|
+
- Reels: ALWAYS require a video (MP4/MOV) — NOT an image. Passing an image to a Reel returns a 400 validation_error. To share an image, use post_type "Post" instead. Ask the user which video to use; use list_media to find available videos.
|
|
184
184
|
- Instagram posts: ALWAYS require at least one image or video.
|
|
185
185
|
- TikTok posts: ALWAYS require at least one image or video.
|
|
186
186
|
- Pinterest posts: ALWAYS require an image AND a board_id.
|
|
@@ -228,6 +228,13 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
228
228
|
link_description: z.string().optional().describe("Optional description for the link-share preview. LinkedIn uses this when set; Facebook auto-fetches the OG description."),
|
|
229
229
|
link_thumbnail_url: z.string().optional().describe("Optional thumbnail image URL for the preview card. Reserved for future use — currently not yet applied to LinkedIn (would require uploading to LinkedIn's image API first)."),
|
|
230
230
|
location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue (with a street address) to tag the post's location. Applied to single-image and carousel Instagram feed posts. To get a valid ID, call the `search_locations` tool with the place name and let the user pick. Ignored by other platforms."),
|
|
231
|
+
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. Invited users get an invite in the Instagram app; once they accept, the post also appears on their profile and feed. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time with a clear error. Ignored by other platforms."),
|
|
232
|
+
user_tags: z.array(z.object({
|
|
233
|
+
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
234
|
+
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
235
|
+
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
236
|
+
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
237
|
+
})).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
|
|
231
238
|
pinterest: z.object({
|
|
232
239
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
233
240
|
title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
|
|
@@ -257,6 +264,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
257
264
|
disable_stitch: z.boolean().optional(),
|
|
258
265
|
is_aigc: z.boolean().optional(),
|
|
259
266
|
brand_content_toggle: z.boolean().optional(),
|
|
267
|
+
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
260
268
|
}).optional().describe("TikTok options"),
|
|
261
269
|
x: z.object({
|
|
262
270
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
@@ -346,7 +354,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
346
354
|
2. **Channels**: Which platforms? Use list_accounts if needed.
|
|
347
355
|
3. **Media** (REQUIRED for some types — same rules as create_post):
|
|
348
356
|
- Stories: ALWAYS need an image or video.
|
|
349
|
-
- Reels: ALWAYS need a video.
|
|
357
|
+
- Reels: ALWAYS need a video (MP4/MOV), NOT an image — passing an image returns a 400 validation_error. Use post_type "Post" to share an image.
|
|
350
358
|
- Instagram/TikTok posts: ALWAYS need at least one image or video.
|
|
351
359
|
- Pinterest: ALWAYS need an image + board_id.
|
|
352
360
|
- Ask the user which media to use. Use list_media to show options.
|
|
@@ -367,6 +375,14 @@ Do NOT call without required media — it will fail.`, {
|
|
|
367
375
|
z.record(z.string(), z.array(z.string())),
|
|
368
376
|
]).optional().describe("External image/video URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
369
377
|
type: z.enum(["post", "story", "reel"]).optional().describe("Content type: 'post' (default), 'story', 'reel'"),
|
|
378
|
+
location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue to tag the post's location. Applied to single-image and carousel Instagram feed posts. Use the `search_locations` tool to find a valid ID. Ignored by other platforms."),
|
|
379
|
+
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time. Ignored by other platforms."),
|
|
380
|
+
user_tags: z.array(z.object({
|
|
381
|
+
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
382
|
+
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
383
|
+
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
384
|
+
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
385
|
+
})).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
|
|
370
386
|
pinterest: z.object({
|
|
371
387
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
372
388
|
title: z.string().optional().describe("Pin title (max 100 characters). For carousel pins (2–5 images) this title applies to the whole pin, not individual slides."),
|
|
@@ -462,6 +478,13 @@ Do NOT call without required media — it will fail.`, {
|
|
|
462
478
|
z.record(z.string(), z.array(z.string())),
|
|
463
479
|
]).optional().describe("External URLs — flat array or per-platform object. Max 10 total. 'default' key is fallback for platforms without their own key. Empty array opts out."),
|
|
464
480
|
location_id: z.string().optional().describe("Instagram only. Facebook Place/Page ID to tag the post's location with. Send an empty string to clear an existing location tag. Ignored by other platforms."),
|
|
481
|
+
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors. Replaces the existing collaborator list. Send an empty array to clear collaborators. Works on image, carousel, and reel posts — NOT Stories. Ignored by other platforms."),
|
|
482
|
+
user_tags: z.array(z.object({
|
|
483
|
+
username: z.string().describe("Public Instagram username to tag. Leading '@' is stripped."),
|
|
484
|
+
x: z.number().min(0).max(1).describe("Horizontal position 0.0–1.0 from the photo's left edge."),
|
|
485
|
+
y: z.number().min(0).max(1).describe("Vertical position 0.0–1.0 from the photo's top edge."),
|
|
486
|
+
image_index: z.number().int().min(0).optional().describe("0-based carousel slide this tag belongs to. Omit (or 0) for a single image."),
|
|
487
|
+
})).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
|
|
465
488
|
youtube: z.object({
|
|
466
489
|
title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
|
|
467
490
|
tags: z.array(z.string()).optional(),
|
|
@@ -491,6 +514,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
491
514
|
disable_stitch: z.boolean().optional(),
|
|
492
515
|
is_aigc: z.boolean().optional(),
|
|
493
516
|
brand_content_toggle: z.boolean().optional(),
|
|
517
|
+
auto_add_music: z.boolean().optional().describe("Photo carousels only. When true, TikTok auto-selects a soundtrack. Defaults to false to avoid unsuitable tracks."),
|
|
494
518
|
}).optional().describe("TikTok options"),
|
|
495
519
|
x: z.object({
|
|
496
520
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
@@ -1,25 +1,67 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
/** Fetch each workspace's name + id so we can match a switch request to it. */
|
|
3
|
+
async function describeWorkspaces(workspaceClients, sessionState) {
|
|
4
|
+
return Promise.all(workspaceClients.map(async (wc, index) => {
|
|
5
|
+
try {
|
|
6
|
+
const result = await wc.client.listAccounts();
|
|
7
|
+
return {
|
|
8
|
+
index,
|
|
9
|
+
workspace_id: result.workspace_id != null ? String(result.workspace_id) : null,
|
|
10
|
+
workspace_name: result.workspace_name || `Workspace ${index + 1}`,
|
|
11
|
+
accounts_count: Array.isArray(result.data) ? result.data.length : 0,
|
|
12
|
+
active: index === sessionState.activeIndex,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {
|
|
17
|
+
index,
|
|
18
|
+
workspace_id: null,
|
|
19
|
+
workspace_name: `Workspace ${index + 1}`,
|
|
20
|
+
accounts_count: 0,
|
|
21
|
+
active: index === sessionState.activeIndex,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve a user-supplied workspace reference (name, id, or positional number)
|
|
28
|
+
* to an index. Order of precedence puts stable identity first so ordering can
|
|
29
|
+
* never send work to the wrong workspace:
|
|
30
|
+
* 1. exact name (case-insensitive)
|
|
31
|
+
* 2. exact workspace id
|
|
32
|
+
* 3. unique partial name match (case-insensitive)
|
|
33
|
+
* 4. positional number fallback (1-based) for back-compat
|
|
34
|
+
* Returns { index } on success, { ambiguous } when a name matches several, or
|
|
35
|
+
* { notFound: true }.
|
|
36
|
+
*/
|
|
37
|
+
function resolveWorkspaceIndex(entries, query) {
|
|
38
|
+
const q = String(query ?? "").trim();
|
|
39
|
+
if (!q)
|
|
40
|
+
return { notFound: true };
|
|
41
|
+
const qLower = q.toLowerCase();
|
|
42
|
+
const exactName = entries.filter((e) => e.workspace_name.toLowerCase() === qLower);
|
|
43
|
+
if (exactName.length === 1)
|
|
44
|
+
return { index: exactName[0].index };
|
|
45
|
+
if (exactName.length > 1)
|
|
46
|
+
return { ambiguous: exactName };
|
|
47
|
+
const byId = entries.filter((e) => e.workspace_id === q);
|
|
48
|
+
if (byId.length === 1)
|
|
49
|
+
return { index: byId[0].index };
|
|
50
|
+
const partial = entries.filter((e) => e.workspace_name.toLowerCase().includes(qLower));
|
|
51
|
+
if (partial.length === 1)
|
|
52
|
+
return { index: partial[0].index };
|
|
53
|
+
if (partial.length > 1)
|
|
54
|
+
return { ambiguous: partial };
|
|
55
|
+
if (/^\d+$/.test(q)) {
|
|
56
|
+
const idx = parseInt(q, 10) - 1;
|
|
57
|
+
if (idx >= 0 && idx < entries.length)
|
|
58
|
+
return { index: idx };
|
|
59
|
+
}
|
|
60
|
+
return { notFound: true };
|
|
61
|
+
}
|
|
2
62
|
export function registerWorkspaceTools(server, workspaceClients, sessionState) {
|
|
3
63
|
server.tool("list_workspaces", "List all workspaces available in this session. Each workspace corresponds to an API key provided at connection time. Shows which workspace is currently active. Workspace selection rule: if only one workspace is available, just use it (no need to ask). If the user has named a workspace in their request (e.g. 'post to my Acme workspace'), proceed with that workspace and remember it for the rest of the conversation. If multiple workspaces exist and the user has NOT named one, call this tool, present the list, and ASK the user which one to use before posting, switching, or fetching analytics. After a successful action, mention the workspace name in your reply for clarity.", {}, async () => {
|
|
4
|
-
const workspaces = await
|
|
5
|
-
try {
|
|
6
|
-
const result = await wc.client.listAccounts();
|
|
7
|
-
return {
|
|
8
|
-
index,
|
|
9
|
-
workspace_name: result.workspace_name || `Workspace ${index + 1}`,
|
|
10
|
-
active: index === sessionState.activeIndex,
|
|
11
|
-
accounts_count: (Array.isArray(result.data) ? result.data : []).length,
|
|
12
|
-
};
|
|
13
|
-
}
|
|
14
|
-
catch {
|
|
15
|
-
return {
|
|
16
|
-
index,
|
|
17
|
-
workspace_name: `Workspace ${index + 1}`,
|
|
18
|
-
active: index === sessionState.activeIndex,
|
|
19
|
-
accounts_count: 0,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
}));
|
|
64
|
+
const workspaces = await describeWorkspaces(workspaceClients, sessionState);
|
|
23
65
|
if (workspaces.length === 1) {
|
|
24
66
|
const ws = workspaces[0];
|
|
25
67
|
return {
|
|
@@ -30,40 +72,48 @@ export function registerWorkspaceTools(server, workspaceClients, sessionState) {
|
|
|
30
72
|
};
|
|
31
73
|
}
|
|
32
74
|
let md = `## Workspaces (${workspaces.length})\n\n`;
|
|
33
|
-
md += `|
|
|
34
|
-
md +=
|
|
75
|
+
md += `| Workspace | Accounts | Status |\n`;
|
|
76
|
+
md += `|-----------|----------|--------|\n`;
|
|
35
77
|
for (const ws of workspaces) {
|
|
36
78
|
const status = ws.active ? "**Active**" : "";
|
|
37
|
-
md += `| ${ws.
|
|
79
|
+
md += `| ${ws.workspace_name} | ${ws.accounts_count} | ${status} |\n`;
|
|
38
80
|
}
|
|
39
|
-
md += `\nUse **switch_workspace** with the workspace
|
|
81
|
+
md += `\nUse **switch_workspace** with the workspace **name** to change the active workspace (e.g. \`switch_workspace workspace: "${workspaces[0].workspace_name}"\`).`;
|
|
40
82
|
return { content: [{ type: "text", text: md }] };
|
|
41
83
|
});
|
|
42
|
-
server.tool("switch_workspace", "Switch the active workspace. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace. Only call this when the user has explicitly named the target workspace, or after the user has picked one from list_workspaces. Never call switch_workspace silently when the user hasn't specified a workspace
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
84
|
+
server.tool("switch_workspace", "Switch the active workspace by NAME. All subsequent tool calls (posts, media, analytics, etc.) will operate on the selected workspace. Pass the workspace name exactly as shown in list_workspaces (its id or list position also work, but the name is preferred and unambiguous). Only call this when the user has explicitly named the target workspace, or after the user has picked one from list_workspaces. Never call switch_workspace silently when the user hasn't specified a workspace - ask first.", {
|
|
85
|
+
workspace: z
|
|
86
|
+
.string()
|
|
87
|
+
.describe('The workspace to switch to: its name (preferred, e.g. "Daily Edge Sports"), or its id/list number.'),
|
|
88
|
+
}, async ({ workspace }) => {
|
|
89
|
+
const entries = await describeWorkspaces(workspaceClients, sessionState);
|
|
90
|
+
const resolved = resolveWorkspaceIndex(entries, workspace);
|
|
91
|
+
if ("ambiguous" in resolved) {
|
|
92
|
+
const names = resolved.ambiguous
|
|
93
|
+
.map((e) => `- ${e.workspace_name}${e.workspace_id ? ` (id ${e.workspace_id})` : ""}`)
|
|
94
|
+
.join("\n");
|
|
47
95
|
return {
|
|
48
96
|
content: [{
|
|
49
97
|
type: "text",
|
|
50
|
-
text: `
|
|
98
|
+
text: `"${workspace}" matches more than one workspace:\n\n${names}\n\nPlease specify the exact name or id.`,
|
|
51
99
|
}],
|
|
52
100
|
};
|
|
53
101
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
102
|
+
if ("notFound" in resolved) {
|
|
103
|
+
const avail = entries.map((e) => e.workspace_name).join(", ");
|
|
104
|
+
return {
|
|
105
|
+
content: [{
|
|
106
|
+
type: "text",
|
|
107
|
+
text: `Error: No workspace matches "${workspace}". Available workspaces: ${avail}. Use list_workspaces to see them.`,
|
|
108
|
+
}],
|
|
109
|
+
};
|
|
61
110
|
}
|
|
62
|
-
|
|
111
|
+
sessionState.activeIndex = resolved.index;
|
|
112
|
+
const ws = entries[resolved.index];
|
|
63
113
|
return {
|
|
64
114
|
content: [{
|
|
65
115
|
type: "text",
|
|
66
|
-
text: `Switched to **${
|
|
116
|
+
text: `Switched to **${ws.workspace_name}**. All subsequent calls will use this workspace.`,
|
|
67
117
|
}],
|
|
68
118
|
};
|
|
69
119
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnisocials/mcp-server",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
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",
|
|
@@ -36,5 +36,10 @@
|
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@types/node": "^25.3.3",
|
|
38
38
|
"typescript": "^5.8.2"
|
|
39
|
+
},
|
|
40
|
+
"overrides": {
|
|
41
|
+
"zod": "4.4.0",
|
|
42
|
+
"fast-uri": "3.1.1",
|
|
43
|
+
"hono": "4.12.18"
|
|
39
44
|
}
|
|
40
45
|
}
|