@omnisocials/mcp-server 1.3.9 → 1.4.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 +5 -0
- package/build/client.d.ts +5 -0
- package/build/client.js +7 -0
- package/build/tools/posts.js +38 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,7 @@ OmniSocials accepts the following channel IDs in `create_post`, `create_and_publ
|
|
|
126
126
|
| `update_post` | Update a draft or scheduled post |
|
|
127
127
|
| `delete_post` | Delete a post |
|
|
128
128
|
| `publish_post` | Publish a draft or scheduled post now |
|
|
129
|
+
| `search_locations` | Find an Instagram location to tag (returns place IDs for `location_id`) |
|
|
129
130
|
|
|
130
131
|
### Media (3 tools)
|
|
131
132
|
|
|
@@ -191,6 +192,10 @@ Full API docs: [docs.omnisocials.com](https://docs.omnisocials.com)
|
|
|
191
192
|
|
|
192
193
|
## Changelog
|
|
193
194
|
|
|
195
|
+
### 1.4.1
|
|
196
|
+
|
|
197
|
+
- **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.
|
|
198
|
+
|
|
194
199
|
### 1.3.8
|
|
195
200
|
|
|
196
201
|
- **Fixed:** Pinterest pins created via `create_post` / `create_and_publish_post` / `update_post` now publish with title, link, video cover, and alt text. Companion server fix — the API was storing those fields under unprefixed keys while the publisher read them under `pinterest_*` prefixes, so pins published with no metadata even when the agent passed it. Existing clients on 1.3.7 benefit automatically once the backend deploys.
|
package/build/client.d.ts
CHANGED
|
@@ -53,6 +53,7 @@ export declare class OmniSocialsClient {
|
|
|
53
53
|
link_title?: string;
|
|
54
54
|
link_description?: string;
|
|
55
55
|
link_thumbnail_url?: string;
|
|
56
|
+
location_id?: string;
|
|
56
57
|
pinterest?: Record<string, unknown>;
|
|
57
58
|
youtube?: Record<string, unknown>;
|
|
58
59
|
instagram?: Record<string, unknown>;
|
|
@@ -71,6 +72,7 @@ export declare class OmniSocialsClient {
|
|
|
71
72
|
link_title?: string;
|
|
72
73
|
link_description?: string;
|
|
73
74
|
link_thumbnail_url?: string;
|
|
75
|
+
location_id?: string;
|
|
74
76
|
pinterest?: Record<string, unknown>;
|
|
75
77
|
youtube?: Record<string, unknown>;
|
|
76
78
|
instagram?: Record<string, unknown>;
|
|
@@ -85,6 +87,7 @@ export declare class OmniSocialsClient {
|
|
|
85
87
|
media_ids?: string[] | Record<string, string[]>;
|
|
86
88
|
media_urls?: string[] | Record<string, string[]>;
|
|
87
89
|
type?: string;
|
|
90
|
+
location_id?: string;
|
|
88
91
|
pinterest?: Record<string, unknown>;
|
|
89
92
|
youtube?: Record<string, unknown>;
|
|
90
93
|
instagram?: Record<string, unknown>;
|
|
@@ -94,6 +97,8 @@ export declare class OmniSocialsClient {
|
|
|
94
97
|
}): Promise<ApiResponse<unknown>>;
|
|
95
98
|
deletePost(id: string): Promise<ApiResponse<unknown>>;
|
|
96
99
|
publishPost(id: string): Promise<ApiResponse<unknown>>;
|
|
100
|
+
searchLocations(query: string): Promise<ApiResponse<unknown>>;
|
|
101
|
+
validateLocation(id: string): Promise<ApiResponse<unknown>>;
|
|
97
102
|
listMedia(params?: {
|
|
98
103
|
limit?: string;
|
|
99
104
|
offset?: string;
|
package/build/client.js
CHANGED
|
@@ -150,6 +150,13 @@ export class OmniSocialsClient {
|
|
|
150
150
|
async publishPost(id) {
|
|
151
151
|
return this.request("POST", `/posts/${id}/publish`);
|
|
152
152
|
}
|
|
153
|
+
// Locations (Instagram place tagging)
|
|
154
|
+
async searchLocations(query) {
|
|
155
|
+
return this.request("GET", "/locations/search", undefined, { q: query });
|
|
156
|
+
}
|
|
157
|
+
async validateLocation(id) {
|
|
158
|
+
return this.request("GET", "/locations/validate", undefined, { id });
|
|
159
|
+
}
|
|
153
160
|
// Media
|
|
154
161
|
async listMedia(params) {
|
|
155
162
|
return this.request("GET", "/media", undefined, params);
|
package/build/tools/posts.js
CHANGED
|
@@ -192,12 +192,10 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
192
192
|
- TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
|
|
193
193
|
- Pinterest carousel — 2–5 images, all same aspect ratio
|
|
194
194
|
|
|
195
|
-
**Per-platform video
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
Facebook Reel **90 s** · YouTube Short **3 min** · X **140 s** · Bluesky **180 s** · Threads **5 min** · TikTok **10 min** · LinkedIn **10 min** · Instagram Reel **15 min** · Pinterest **15 min** · Reddit **15 min** · Facebook Post **240 min** · Mastodon (instance-dependent).
|
|
200
|
-
When a cap is exceeded, the API returns \`400 { code: "validation_error", message: "<Platform> only allows videos up to <cap> — yours is <duration>. Trim the video or deselect <Platform>." }\`. If you know the video is over a platform's cap before sending, warn the user and either trim, deselect that platform, or skip publishing to it.
|
|
195
|
+
**Per-platform video caps (duration + size, checked via ffprobe at submit — exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s — yours is 2min 35s. Trim the video or deselect X."):**
|
|
196
|
+
- X — **140s (2min 20s)** · **512MB** (X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images)
|
|
197
|
+
- Bluesky 180s · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 10min · Pinterest 15min · Facebook Reel 90s
|
|
198
|
+
Pick a file within the cap up front; if the user's video is over, tell them the limit so they can trim it rather than retrying blindly.
|
|
201
199
|
|
|
202
200
|
When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
|
|
203
201
|
5. **Platform-specific options** (ask only when relevant):
|
|
@@ -208,13 +206,13 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
208
206
|
If the user named a board ("post to my Marketing board") or specified one in the request, use that one instead of the first — match on name (case-insensitive) against the boards list.
|
|
209
207
|
- YouTube: Title, privacy status, tags?
|
|
210
208
|
- TikTok: Privacy level?
|
|
211
|
-
- **X threads
|
|
209
|
+
- **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars); the \`content\` field is then ignored for X. A single **long-form** post on a Premium / Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On free / Basic, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
|
|
212
210
|
|
|
213
211
|
Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
|
|
214
212
|
|
|
215
213
|
**When the user shares an image in chat but you cannot upload it** (e.g. no public URL available): save the post as a draft with the caption, then tell the user: "I saved your post as a draft in OmniSocials. Open it there to add your image and schedule it when ready." Include a link to https://app.omnisocials.com. Do NOT tell the user it's impossible — always save the draft so their caption isn't lost.`, {
|
|
216
214
|
content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
|
|
217
|
-
channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
|
|
215
|
+
channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
|
|
218
216
|
scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing"),
|
|
219
217
|
media_ids: z.union([
|
|
220
218
|
z.array(z.string()),
|
|
@@ -229,6 +227,7 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
229
227
|
link_title: z.string().optional().describe("Optional title for the link-share preview. LinkedIn uses this when set; Facebook ignores it and fetches OG metadata server-side. Omit to let LinkedIn auto-fetch the page title."),
|
|
230
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."),
|
|
231
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
|
+
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."),
|
|
232
231
|
pinterest: z.object({
|
|
233
232
|
board_id: z.string().optional().describe("Pinterest board ID. Required for Pinterest. Use get_account to list boards."),
|
|
234
233
|
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."),
|
|
@@ -351,14 +350,13 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
351
350
|
- Pinterest: ALWAYS need an image + board_id.
|
|
352
351
|
- Ask the user which media to use. Use list_media to show options.
|
|
353
352
|
- **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos with no photo/video mix, Pinterest carousel 2–5 same-ratio images.
|
|
354
|
-
- **Video duration caps** (
|
|
355
|
-
- **Video size caps** (same as create_post): Mastodon 99 MB · Bluesky 100 MB · IG 300 MB · X 512 MB · Threads/Reddit 1 GB · Pinterest 2 GB · Facebook/TikTok 4 GB · LinkedIn 5 GB. Returns 400 with "This video (<size>) is too large for <Platform>" when exceeded.
|
|
353
|
+
- **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** (no video+image mix in one tweet), Bluesky 180s, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 10min, Facebook Reel 90s.
|
|
356
354
|
4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
|
|
357
|
-
5. **X threads
|
|
355
|
+
5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars). A single long-form post on a Premium / Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On free / Basic, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread.
|
|
358
356
|
|
|
359
357
|
Do NOT call without required media — it will fail.`, {
|
|
360
358
|
content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
|
|
361
|
-
channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
|
|
359
|
+
channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
|
|
362
360
|
media_ids: z.union([
|
|
363
361
|
z.array(z.string()),
|
|
364
362
|
z.record(z.string(), z.array(z.string())),
|
|
@@ -452,7 +450,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
452
450
|
id: z.string().describe("The post ID to update"),
|
|
453
451
|
content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post caption / body text. For YouTube Shorts this becomes the video description, NOT the title — to rename the Short, use `youtube.title`. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
|
|
454
452
|
scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601)"),
|
|
455
|
-
channels: z.array(z.string()).optional().describe("Updated channel IDs. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
|
|
453
|
+
channels: z.array(z.string()).optional().describe("Updated channel IDs. Bare platform name or composite `\"<workspace_id>_<platform>\"`; always source from list_accounts for this API key (unknown or mismatched workspace prefixes are rejected as unknown accounts). Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
|
|
456
454
|
media_ids: z.union([
|
|
457
455
|
z.array(z.string()),
|
|
458
456
|
z.record(z.string(), z.array(z.string())),
|
|
@@ -461,6 +459,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
461
459
|
z.array(z.string()),
|
|
462
460
|
z.record(z.string(), z.array(z.string())),
|
|
463
461
|
]).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
|
+
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."),
|
|
464
463
|
youtube: z.object({
|
|
465
464
|
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)."),
|
|
466
465
|
tags: z.array(z.string()).optional(),
|
|
@@ -573,4 +572,30 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
573
572
|
content: [{ type: "text", text: md }],
|
|
574
573
|
};
|
|
575
574
|
});
|
|
575
|
+
server.tool("search_locations", `Search for an Instagram location to tag on a post. Use this whenever the user wants to post WITH a place/location (e.g. "post this at my dealership", "tag the café"). It returns matching real venues with their addresses and a location ID.
|
|
576
|
+
|
|
577
|
+
Flow: call this with the place name → present the options to the user → once they pick, pass that result's \`id\` as \`location_id\` on create_post / create_and_publish_post / update_post. Only Instagram supports location tagging.
|
|
578
|
+
|
|
579
|
+
Notes:
|
|
580
|
+
- Use a SPECIFIC venue name. Searching a broad brand ("Starbucks") returns individual store locations, not the brand page.
|
|
581
|
+
- If the user already gives you a numeric Facebook Place ID, you can pass it straight to create_post as \`location_id\`; it's validated at publish time and an invalid one returns a clear error.
|
|
582
|
+
- If results come back empty with a permission note, the workspace's Facebook app can't search arbitrary places — tell the user to use their own business Page's ID.`, {
|
|
583
|
+
query: z
|
|
584
|
+
.string()
|
|
585
|
+
.describe("Place name to search (min 2 chars) — a dealership, café, venue, etc."),
|
|
586
|
+
}, async ({ query }) => {
|
|
587
|
+
const result = await getClient().searchLocations(query);
|
|
588
|
+
const places = result?.data || [];
|
|
589
|
+
if (!places.length) {
|
|
590
|
+
const why = result?.error ||
|
|
591
|
+
`No taggable locations found for "${query}". Try a more specific venue name, or pass a known Facebook Place ID directly as location_id.`;
|
|
592
|
+
return { content: [{ type: "text", text: why }] };
|
|
593
|
+
}
|
|
594
|
+
let md = `## Locations matching "${query}"\n\nAsk the user which one, then use its ID as \`location_id\`:\n\n`;
|
|
595
|
+
md += `| # | Name | Address | location_id |\n|---|------|---------|-------------|\n`;
|
|
596
|
+
places.forEach((p, i) => {
|
|
597
|
+
md += `| ${i + 1} | ${p.name} | ${p.address || "—"} | \`${p.id}\` |\n`;
|
|
598
|
+
});
|
|
599
|
+
return { content: [{ type: "text", text: md }] };
|
|
600
|
+
});
|
|
576
601
|
}
|
package/package.json
CHANGED