@omnisocials/mcp-server 1.4.1 → 1.7.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 +12 -4
- package/build/client.d.ts +39 -1
- package/build/client.js +10 -0
- package/build/index.js +1 -1
- package/build/tools/analytics.js +1 -1
- package/build/tools/media.js +80 -14
- package/build/tools/posts.js +35 -8
- 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
|
|
|
@@ -192,6 +195,11 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
192
195
|
|
|
193
196
|
## Changelog
|
|
194
197
|
|
|
198
|
+
### 1.5.0
|
|
199
|
+
|
|
200
|
+
- **Added:** Attach uploaded media to any tweet in an X thread. `x.thread_parts[]` now accepts `media_ids` (the same numeric Library IDs returned by `upload_media`) on any part, first tweet or reply, alongside the existing `media_urls`. Combined cap is 4 media per part. This makes graphic-led threads possible without self-hosting image URLs: for example a graphic on the first tweet and the signup link alone in a reply (which keeps the first tweet's reach). Companion server change: media attached to the parent post was previously dropped in thread mode and is now folded into the first tweet.
|
|
201
|
+
- **Changed:** `upload_media` now shows the uploaded file's public URL in its output, so it can be reused anywhere `media_urls` is accepted.
|
|
202
|
+
|
|
195
203
|
### 1.4.1
|
|
196
204
|
|
|
197
205
|
- **Fixed:** Long-form X posts from Premium / Premium+ accounts are no longer wrongly capped at 280 characters. Companion server fix — the API validated X text without checking the account's subscription tier, so single posts over 280 chars were rejected even on Premium. Existing clients benefit automatically once the backend deploys; 1.4.1 only refreshes the tool guidance so agents put Premium long-form (up to 25,000 chars) in `content` instead of force-splitting it into a thread. Note: X *threads* still cap each part at 280 chars regardless of tier.
|
package/build/client.d.ts
CHANGED
|
@@ -12,7 +12,9 @@ export declare function capitalize(str: string): string;
|
|
|
12
12
|
export interface XThreadPartInput {
|
|
13
13
|
/** Tweet text — ≤ 280 chars (the API enforces 280 even for X Premium). */
|
|
14
14
|
text: string;
|
|
15
|
-
/** Optional per-part media
|
|
15
|
+
/** Optional per-part media as Library IDs from upload_media (max 4 combined with media_urls). */
|
|
16
|
+
media_ids?: string[];
|
|
17
|
+
/** Optional per-part media as external URLs (max 4 combined with media_ids). */
|
|
16
18
|
media_urls?: string[];
|
|
17
19
|
}
|
|
18
20
|
export interface XPostOptions {
|
|
@@ -54,6 +56,13 @@ export declare class OmniSocialsClient {
|
|
|
54
56
|
link_description?: string;
|
|
55
57
|
link_thumbnail_url?: string;
|
|
56
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
|
+
}>;
|
|
57
66
|
pinterest?: Record<string, unknown>;
|
|
58
67
|
youtube?: Record<string, unknown>;
|
|
59
68
|
instagram?: Record<string, unknown>;
|
|
@@ -73,6 +82,13 @@ export declare class OmniSocialsClient {
|
|
|
73
82
|
link_description?: string;
|
|
74
83
|
link_thumbnail_url?: string;
|
|
75
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
|
+
}>;
|
|
76
92
|
pinterest?: Record<string, unknown>;
|
|
77
93
|
youtube?: Record<string, unknown>;
|
|
78
94
|
instagram?: Record<string, unknown>;
|
|
@@ -88,6 +104,13 @@ export declare class OmniSocialsClient {
|
|
|
88
104
|
media_urls?: string[] | Record<string, string[]>;
|
|
89
105
|
type?: string;
|
|
90
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
|
+
}>;
|
|
91
114
|
pinterest?: Record<string, unknown>;
|
|
92
115
|
youtube?: Record<string, unknown>;
|
|
93
116
|
instagram?: Record<string, unknown>;
|
|
@@ -102,17 +125,32 @@ export declare class OmniSocialsClient {
|
|
|
102
125
|
listMedia(params?: {
|
|
103
126
|
limit?: string;
|
|
104
127
|
offset?: string;
|
|
128
|
+
search?: string;
|
|
129
|
+
folder_id?: string;
|
|
105
130
|
}): Promise<ApiResponse<unknown>>;
|
|
106
131
|
uploadMedia(data: {
|
|
107
132
|
url: string;
|
|
108
133
|
filename?: string;
|
|
134
|
+
name?: string;
|
|
135
|
+
folder?: string;
|
|
109
136
|
}): Promise<ApiResponse<unknown>>;
|
|
110
137
|
uploadMediaFromBase64(data: {
|
|
111
138
|
data: string;
|
|
112
139
|
mime_type: string;
|
|
113
140
|
filename?: string;
|
|
141
|
+
name?: string;
|
|
142
|
+
folder?: string;
|
|
114
143
|
}): Promise<ApiResponse<unknown>>;
|
|
115
144
|
deleteMedia(id: string): Promise<ApiResponse<unknown>>;
|
|
145
|
+
updateMedia(id: string, data: {
|
|
146
|
+
name?: string;
|
|
147
|
+
folder_id?: string | null;
|
|
148
|
+
}): Promise<ApiResponse<unknown>>;
|
|
149
|
+
listFolders(): Promise<ApiResponse<unknown>>;
|
|
150
|
+
createFolder(data: {
|
|
151
|
+
name: string;
|
|
152
|
+
parent_id?: string;
|
|
153
|
+
}): Promise<ApiResponse<unknown>>;
|
|
116
154
|
listAccounts(): Promise<ApiResponse<unknown>>;
|
|
117
155
|
getAccount(id: string): Promise<ApiResponse<unknown>>;
|
|
118
156
|
getPostAnalytics(postId: string): Promise<ApiResponse<unknown>>;
|
package/build/client.js
CHANGED
|
@@ -170,6 +170,16 @@ export class OmniSocialsClient {
|
|
|
170
170
|
async deleteMedia(id) {
|
|
171
171
|
return this.request("DELETE", `/media/${id}`);
|
|
172
172
|
}
|
|
173
|
+
async updateMedia(id, data) {
|
|
174
|
+
return this.request("PATCH", `/media/${id}`, data);
|
|
175
|
+
}
|
|
176
|
+
// Folders
|
|
177
|
+
async listFolders() {
|
|
178
|
+
return this.request("GET", "/folders");
|
|
179
|
+
}
|
|
180
|
+
async createFolder(data) {
|
|
181
|
+
return this.request("POST", "/folders", data);
|
|
182
|
+
}
|
|
173
183
|
// Accounts
|
|
174
184
|
async listAccounts() {
|
|
175
185
|
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.0",
|
|
31
31
|
});
|
|
32
32
|
// Register all tools - pass getter function so tools always use the active workspace's client
|
|
33
33
|
registerPostTools(server, getActiveClient);
|
package/build/tools/analytics.js
CHANGED
|
@@ -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} | ${formatBytes(m.size || 0)} | \`${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) => {
|
|
@@ -56,6 +62,8 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
56
62
|
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
63
|
mime_type: z.string().optional().describe("MIME type of the file (e.g. 'image/jpeg', 'image/png'). Required when using base64_data."),
|
|
58
64
|
filename: z.string().optional().describe("Optional filename for the uploaded media"),
|
|
65
|
+
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.'),
|
|
66
|
+
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
67
|
}, async (params) => {
|
|
60
68
|
let result;
|
|
61
69
|
if (params.base64_data && params.mime_type) {
|
|
@@ -63,10 +71,12 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
63
71
|
data: params.base64_data,
|
|
64
72
|
mime_type: params.mime_type,
|
|
65
73
|
filename: params.filename,
|
|
74
|
+
name: params.name || params.filename,
|
|
75
|
+
folder: params.folder,
|
|
66
76
|
});
|
|
67
77
|
}
|
|
68
78
|
else if (params.url) {
|
|
69
|
-
result = await getClient().uploadMedia({ url: params.url, filename: params.filename });
|
|
79
|
+
result = await getClient().uploadMedia({ url: params.url, filename: params.filename, name: params.name || params.filename, folder: params.folder });
|
|
70
80
|
}
|
|
71
81
|
else {
|
|
72
82
|
return {
|
|
@@ -83,20 +93,76 @@ When the user provides an image in the conversation (not a URL), use base64_data
|
|
|
83
93
|
md += `| Field | Value |\n`;
|
|
84
94
|
md += `|-------|-------|\n`;
|
|
85
95
|
md += `| **Media ID** | \`${m.id}\` |\n`;
|
|
96
|
+
md += `| **Name** | ${m.name || m.filename || "—"} |\n`;
|
|
86
97
|
md += `| **Type** | ${m.type || "—"} |\n`;
|
|
87
|
-
|
|
98
|
+
if (m.folder_id)
|
|
99
|
+
md += `| **Folder** | \`${m.folder_id}\` |\n`;
|
|
88
100
|
md += `| **Size** | ${formatBytes(m.size || 0)} |\n`;
|
|
89
|
-
|
|
101
|
+
if (m.url)
|
|
102
|
+
md += `| **URL** | ${m.url} |\n`;
|
|
103
|
+
md += `\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.`;
|
|
90
104
|
return {
|
|
91
105
|
content: [{ type: "text", text: md }],
|
|
92
106
|
};
|
|
93
107
|
});
|
|
94
|
-
server.tool("
|
|
95
|
-
id: z.string().describe("The media ID to
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
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.", {
|
|
109
|
+
id: z.string().describe("The media ID to update"),
|
|
110
|
+
name: z.string().optional().describe('New human-readable label, e.g. "pp-play5get50". Pass an empty string to clear it.'),
|
|
111
|
+
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").'),
|
|
112
|
+
}, async ({ id, name, folder_id }) => {
|
|
113
|
+
const body = {};
|
|
114
|
+
if (name !== undefined)
|
|
115
|
+
body.name = name;
|
|
116
|
+
if (folder_id !== undefined)
|
|
117
|
+
body.folder_id = folder_id === "" ? null : folder_id;
|
|
118
|
+
const result = await getClient().updateMedia(id, body);
|
|
119
|
+
if (result.error) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const m = result.data;
|
|
125
|
+
return {
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "text",
|
|
129
|
+
text: `Updated media \`${m.id}\`.\n\n| Field | Value |\n|-------|-------|\n| **Name** | ${m.name || "—"} |\n| **Folder** | ${m.folder_id ? `\`${m.folder_id}\`` : "root"} |`,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
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 () => {
|
|
135
|
+
const result = await getClient().listFolders();
|
|
136
|
+
if (result.error) {
|
|
137
|
+
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
138
|
+
}
|
|
139
|
+
const items = Array.isArray(result.data) ? result.data : result.data?.data || [];
|
|
140
|
+
if (!items.length) {
|
|
141
|
+
return { content: [{ type: "text", text: "No folders yet. Create one with create_folder." }] };
|
|
142
|
+
}
|
|
143
|
+
let md = `## Folders (${items.length})\n\n`;
|
|
144
|
+
md += `| Folder ID | Name | Parent | Items |\n|-----------|------|--------|-------|\n`;
|
|
145
|
+
for (const f of items) {
|
|
146
|
+
md += `| \`${f.id}\` | ${f.name} | ${f.parent_id ? `\`${f.parent_id}\`` : "—"} | ${f.item_count ?? 0} |\n`;
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: "text", text: md }] };
|
|
149
|
+
});
|
|
150
|
+
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).", {
|
|
151
|
+
name: z.string().describe("Folder name, e.g. 'win-graphics'"),
|
|
152
|
+
parent_id: z.string().optional().describe("Optional parent folder id to nest under (from list_folders). Omit for a top-level folder."),
|
|
153
|
+
}, async ({ name, parent_id }) => {
|
|
154
|
+
const result = await getClient().createFolder({ name, parent_id });
|
|
155
|
+
if (result.error) {
|
|
156
|
+
return { content: [{ type: "text", text: `Error (${result.error.code}): ${result.error.message}` }] };
|
|
157
|
+
}
|
|
158
|
+
const f = result.data;
|
|
98
159
|
return {
|
|
99
|
-
content: [
|
|
160
|
+
content: [
|
|
161
|
+
{
|
|
162
|
+
type: "text",
|
|
163
|
+
text: `Created folder \`${f.id}\` "${f.name}". Upload into it with upload_media (folder="${f.name}").`,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
100
166
|
};
|
|
101
167
|
});
|
|
102
168
|
}
|
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(),
|
|
@@ -264,8 +272,9 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
264
272
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
265
273
|
thread_parts: z.array(z.object({
|
|
266
274
|
text: z.string().describe("Tweet text (≤ 280 chars)"),
|
|
267
|
-
|
|
268
|
-
|
|
275
|
+
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
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
|
|
277
|
+
})).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."),
|
|
269
278
|
}).optional().describe("X (Twitter) options"),
|
|
270
279
|
google_business: z.object({
|
|
271
280
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional().describe("Local post type. Defaults to STANDARD. ALERT is reserved by Google and not exposed."),
|
|
@@ -345,7 +354,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
345
354
|
2. **Channels**: Which platforms? Use list_accounts if needed.
|
|
346
355
|
3. **Media** (REQUIRED for some types — same rules as create_post):
|
|
347
356
|
- Stories: ALWAYS need an image or video.
|
|
348
|
-
- 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.
|
|
349
358
|
- Instagram/TikTok posts: ALWAYS need at least one image or video.
|
|
350
359
|
- Pinterest: ALWAYS need an image + board_id.
|
|
351
360
|
- Ask the user which media to use. Use list_media to show options.
|
|
@@ -366,6 +375,14 @@ Do NOT call without required media — it will fail.`, {
|
|
|
366
375
|
z.record(z.string(), z.array(z.string())),
|
|
367
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."),
|
|
368
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."),
|
|
369
386
|
pinterest: z.object({
|
|
370
387
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
371
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."),
|
|
@@ -387,8 +404,9 @@ Do NOT call without required media — it will fail.`, {
|
|
|
387
404
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
388
405
|
thread_parts: z.array(z.object({
|
|
389
406
|
text: z.string().describe("Tweet text (≤ 280 chars)"),
|
|
390
|
-
|
|
391
|
-
|
|
407
|
+
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
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
|
|
409
|
+
})).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."),
|
|
392
410
|
}).optional().describe("X (Twitter) options"),
|
|
393
411
|
google_business: z.object({
|
|
394
412
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).optional(),
|
|
@@ -460,6 +478,13 @@ Do NOT call without required media — it will fail.`, {
|
|
|
460
478
|
z.record(z.string(), z.array(z.string())),
|
|
461
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."),
|
|
462
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."),
|
|
463
488
|
youtube: z.object({
|
|
464
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)."),
|
|
465
490
|
tags: z.array(z.string()).optional(),
|
|
@@ -489,6 +514,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
489
514
|
disable_stitch: z.boolean().optional(),
|
|
490
515
|
is_aigc: z.boolean().optional(),
|
|
491
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."),
|
|
492
518
|
}).optional().describe("TikTok options"),
|
|
493
519
|
x: z.object({
|
|
494
520
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
@@ -496,8 +522,9 @@ Do NOT call without required media — it will fail.`, {
|
|
|
496
522
|
made_with_ai: z.boolean().optional(),
|
|
497
523
|
thread_parts: z.array(z.object({
|
|
498
524
|
text: z.string().describe("Tweet text (≤ 280 chars)"),
|
|
499
|
-
|
|
500
|
-
|
|
525
|
+
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
|
+
media_urls: z.array(z.string()).max(4).optional().describe("Per-tweet media as external URLs (max 4 combined with media_ids)"),
|
|
527
|
+
})).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."),
|
|
501
528
|
}).optional().describe("X (Twitter) options"),
|
|
502
529
|
google_business: z.object({
|
|
503
530
|
topic_type: z.enum(["STANDARD", "EVENT", "OFFER"]).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.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",
|
|
@@ -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
|
}
|