planpost-mcp 0.2.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/LICENSE +21 -0
- package/README.md +154 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +875 -0
- package/package.json +49 -0
- package/skills/send-to-plan-post/SKILL.md +152 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lukasz Blachura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# planpost-mcp
|
|
2
|
+
|
|
3
|
+
MCP (Model Context Protocol) server for [PlanPost](https://planpo.st) — lets Claude and other AI agents schedule social media posts, create videos and carousels, manage content sources, and read analytics and revenue data by talking to your PlanPost account.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
### 1. Get an API key
|
|
8
|
+
|
|
9
|
+
In the PlanPost dashboard go to **Settings → API Keys → Create Key** and select the permissions you need (see the table below). The key (`pk_live_…`) is shown **once** — copy it.
|
|
10
|
+
|
|
11
|
+
> API/MCP access is included in every paid plan — Creator: 1,000 calls/month, Pro: 10,000 calls/month. Keys are scoped: a key with only `posts:read` cannot publish.
|
|
12
|
+
|
|
13
|
+
### 2. Add the server to your agent
|
|
14
|
+
|
|
15
|
+
**Claude Code:**
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
claude mcp add planpost -e PLANPOST_API_KEY=pk_live_xxx -- npx -y planpost-mcp
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Claude Desktop** (`claude_desktop_config.json`):
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"mcpServers": {
|
|
26
|
+
"planpost": {
|
|
27
|
+
"command": "npx",
|
|
28
|
+
"args": ["-y", "planpost-mcp"],
|
|
29
|
+
"env": { "PLANPOST_API_KEY": "pk_live_xxx" }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
**Any other MCP client:** run `npx -y planpost-mcp` over stdio with the `PLANPOST_API_KEY` environment variable set.
|
|
36
|
+
|
|
37
|
+
### Remote (HTTP) mode
|
|
38
|
+
|
|
39
|
+
The server can also run as a shared, multi-tenant HTTP endpoint — no local install for users, each request authenticated by its own key:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npx -y planpost-mcp --http --port 3020 # exposes POST /mcp (+ /health)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Connect from any HTTP-capable MCP client, e.g.:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
claude mcp add --transport http planpost https://mcp.yourhost.com/mcp \
|
|
49
|
+
--header "Authorization: Bearer pk_live_xxx"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...` header is required on every request and scoped per request, so one hosted instance serves many users safely. Put it behind HTTPS (reverse proxy) in production.
|
|
53
|
+
|
|
54
|
+
### 3. Talk to it
|
|
55
|
+
|
|
56
|
+
> "Schedule this post to my Polish Pages group for tomorrow 9:00: …"
|
|
57
|
+
>
|
|
58
|
+
> "Make a quote video from this testimonial and publish it as a reel."
|
|
59
|
+
>
|
|
60
|
+
> "Pull the latest posts from my WordPress blog and draft a LinkedIn carousel from the newest one."
|
|
61
|
+
>
|
|
62
|
+
> "How much revenue did my posts drive last 30 days?"
|
|
63
|
+
|
|
64
|
+
## Environment variables
|
|
65
|
+
|
|
66
|
+
| Variable | Required | Description |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `PLANPOST_API_KEY` | ✅ | API key from Settings → API Keys (`pk_live_…`) |
|
|
69
|
+
| `PLANPOST_API_URL` | — | API base URL (default `https://api.planpo.st`) |
|
|
70
|
+
|
|
71
|
+
## Tools
|
|
72
|
+
|
|
73
|
+
### Posts & publishing
|
|
74
|
+
|
|
75
|
+
| Tool | Required permission | Description |
|
|
76
|
+
|---|---|---|
|
|
77
|
+
| `list_posts` | `posts:read` | List posts, filter by status/platform |
|
|
78
|
+
| `get_post` | `posts:read` | Fetch one post |
|
|
79
|
+
| `create_post` | `posts:write` | Create a draft or scheduled post (per-platform content/settings/media, UTM overrides, timezone) |
|
|
80
|
+
| `update_post` | `posts:write` | Update a draft/scheduled post |
|
|
81
|
+
| `delete_post` | `posts:write` | Delete a post |
|
|
82
|
+
| `publish_post` | `posts:write` | Publish immediately |
|
|
83
|
+
| `upload_media` | `media:write` | Get a presigned URL to upload an image/video |
|
|
84
|
+
| `repurpose_content` | `repurpose:write` | Save repurposed content as draft posts |
|
|
85
|
+
|
|
86
|
+
### Accounts
|
|
87
|
+
|
|
88
|
+
| Tool | Required permission | Description |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| `list_connections` | `connections:read` | Connected social accounts |
|
|
91
|
+
| `list_account_groups` | `groups:read` | Named account groups (e.g. "Polish Pages") |
|
|
92
|
+
| `get_account_group` | `groups:read` | Resolve a group by id/name/default → connection IDs |
|
|
93
|
+
|
|
94
|
+
Connecting a new social account requires OAuth in the dashboard — agents can target any account that's already connected.
|
|
95
|
+
|
|
96
|
+
### Video & carousels
|
|
97
|
+
|
|
98
|
+
| Tool | Required permission | Description |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `list_video_templates` | `videos:read` | Browse video templates and their editable fields |
|
|
101
|
+
| `create_video` | `videos:write` | Submit a render job from a template |
|
|
102
|
+
| `get_video_status` | `videos:read` | Poll a render; completed renders return download URLs |
|
|
103
|
+
| `generate_carousel` | `carousels:write` | Decompose long-form content into carousel slide text |
|
|
104
|
+
| `list_carousel_styles` | `carousels:read` | Template families, 40-palette library, formats, font pairings |
|
|
105
|
+
| `render_carousel` | `carousels:write` | Render slides into branded PNGs in the media library |
|
|
106
|
+
| `get_carousel_render` | `carousels:read` | Poll a render; completed renders return post-ready media assets |
|
|
107
|
+
| `list_brand_kits` | `brand-kits:read` | Saved brand kits (palette, fonts, logo, handle) for `render_carousel` |
|
|
108
|
+
|
|
109
|
+
### Content sources
|
|
110
|
+
|
|
111
|
+
| Tool | Required permission | Description |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `list_content_sources` | `content-sources:read` | List WordPress/RSS sources |
|
|
114
|
+
| `add_content_source` | `content-sources:write` | Connect a WordPress site or RSS feed |
|
|
115
|
+
| `delete_content_source` | `content-sources:write` | Remove a source |
|
|
116
|
+
| `fetch_source_content` | `content-sources:write` | Import the latest content now |
|
|
117
|
+
| `list_imported_content` | `content-sources:read` | List imported content for repurposing |
|
|
118
|
+
|
|
119
|
+
### Analytics & revenue
|
|
120
|
+
|
|
121
|
+
| Tool | Required permission | Description |
|
|
122
|
+
|---|---|---|
|
|
123
|
+
| `get_analytics` | `analytics:read` | Post performance (impressions, likes, comments, shares) |
|
|
124
|
+
| `get_revenue_overview` | `revenue:read` | Revenue totals + trend; optionally recent transactions and Stripe/RevenueCat MRR |
|
|
125
|
+
|
|
126
|
+
Revenue data is read-only over the API; connecting Stripe/RevenueCat/Shopify happens in the dashboard (Settings → Revenue).
|
|
127
|
+
|
|
128
|
+
## A typical agent workflow
|
|
129
|
+
|
|
130
|
+
1. `get_account_group` (`default: true`) → connection IDs
|
|
131
|
+
2. `list_video_templates` → pick a template → `create_video` → poll `get_video_status`
|
|
132
|
+
3. `upload_media` (or use the render URL) → `create_post` with `scheduledFor`
|
|
133
|
+
4. Later: `get_analytics` and `get_revenue_overview` to report results
|
|
134
|
+
|
|
135
|
+
Or, for a branded carousel post end-to-end:
|
|
136
|
+
|
|
137
|
+
1. `generate_carousel` (long-form content → slide text)
|
|
138
|
+
2. `list_carousel_styles` / `list_brand_kits` → pick a family + palette or kit
|
|
139
|
+
3. `render_carousel` → poll `get_carousel_render` → completed `assets`
|
|
140
|
+
4. `create_post` with those assets as `media` (they're already attachment-shaped)
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm install
|
|
146
|
+
npm run build # tsc → dist/
|
|
147
|
+
PLANPOST_API_KEY=pk_test_xxx node dist/index.js
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The server speaks MCP over stdio. It calls the PlanPost public API (`/api/public/v1`) with your key; nothing else is stored or sent anywhere.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,875 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { createServer as createHttpServer } from "node:http";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
import { createRequire } from "node:module";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
const ENV_API_KEY = process.env.PLANPOST_API_KEY;
|
|
10
|
+
const BASE_URL = process.env.PLANPOST_API_URL || "https://api.planpo.st";
|
|
11
|
+
// Single source of truth for the version: package.json ships in every tarball,
|
|
12
|
+
// and dist/index.js always sits one level below it. Hardcoding it here meant
|
|
13
|
+
// the two drifted on the first release that forgot to update both.
|
|
14
|
+
const { version } = createRequire(import.meta.url)("../package.json");
|
|
15
|
+
const SERVER_INFO = { name: "planpost", version };
|
|
16
|
+
// In HTTP mode each request carries its own key (Authorization header); the
|
|
17
|
+
// AsyncLocalStorage context survives across the awaits inside tool handlers,
|
|
18
|
+
// so concurrent requests with different keys can't bleed into each other.
|
|
19
|
+
// Stdio mode falls back to the env key.
|
|
20
|
+
const requestApiKey = new AsyncLocalStorage();
|
|
21
|
+
function resolveApiKey() {
|
|
22
|
+
const key = requestApiKey.getStore() || ENV_API_KEY;
|
|
23
|
+
if (!key) {
|
|
24
|
+
throw new Error("No PlanPost API key: set PLANPOST_API_KEY (stdio) or send Authorization: Bearer pk_... (http)");
|
|
25
|
+
}
|
|
26
|
+
return key;
|
|
27
|
+
}
|
|
28
|
+
// ─── API Client ───
|
|
29
|
+
async function api(path, options = {}) {
|
|
30
|
+
const url = `${BASE_URL}/api/public/v1${path}`;
|
|
31
|
+
const res = await fetch(url, {
|
|
32
|
+
...options,
|
|
33
|
+
headers: {
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
Authorization: `Bearer ${resolveApiKey()}`,
|
|
36
|
+
...options.headers,
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
if (!res.ok) {
|
|
41
|
+
const msg = data?.error?.message || data?.message || `API error ${res.status}`;
|
|
42
|
+
throw new Error(msg);
|
|
43
|
+
}
|
|
44
|
+
return data;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Renders the API's advisory `warnings[]` as a readable block. These never
|
|
48
|
+
* block a write — they flag what will happen at publish time (truncation,
|
|
49
|
+
* dropped media, a flag that lost to postType) so the caller can fix it now.
|
|
50
|
+
*/
|
|
51
|
+
function formatWarnings(warnings) {
|
|
52
|
+
if (!Array.isArray(warnings) || warnings.length === 0)
|
|
53
|
+
return "";
|
|
54
|
+
const lines = warnings.map((w) => ` - [${w.code}]${w.platform ? ` ${w.platform}:` : ""} ${w.message}`);
|
|
55
|
+
return `\n\n⚠️ ${warnings.length} warning(s):\n${lines.join("\n")}`;
|
|
56
|
+
}
|
|
57
|
+
/** Renders the dashboard deep links returned alongside a created/updated post. */
|
|
58
|
+
function formatLinks(links) {
|
|
59
|
+
if (!links || typeof links !== "object")
|
|
60
|
+
return "";
|
|
61
|
+
const { edit, view } = links;
|
|
62
|
+
if (!edit && !view)
|
|
63
|
+
return "";
|
|
64
|
+
return `\n\nOpen in PlanPost:${edit ? `\n Edit: ${edit}` : ""}${view ? `\n View: ${view}` : ""}`;
|
|
65
|
+
}
|
|
66
|
+
// ─── MCP Server ───
|
|
67
|
+
// Tool registration is a function so HTTP mode can build a fresh server per
|
|
68
|
+
// request (stateless transport); stdio builds one at startup.
|
|
69
|
+
function buildServer() {
|
|
70
|
+
const server = new McpServer(SERVER_INFO);
|
|
71
|
+
registerTools(server);
|
|
72
|
+
return server;
|
|
73
|
+
}
|
|
74
|
+
// ─── Tools ───
|
|
75
|
+
function registerTools(server) {
|
|
76
|
+
server.tool("list_posts", "List your scheduled and published social media posts. Filter by status, platform, campaign, label, post type, or free text.", {
|
|
77
|
+
status: z.enum(["draft", "scheduled", "processing", "published", "failed", "cancelled"]).optional().describe("Filter by post status"),
|
|
78
|
+
platform: z.string().optional().describe("Filter by platform (instagram, twitter, linkedin, etc.)"),
|
|
79
|
+
campaign: z.string().optional().describe("Filter by campaign tag, e.g. 'garmin-cirqa-2026' (matches utm.campaign; slugified automatically)"),
|
|
80
|
+
label: z.string().optional().describe("Filter by internal label (case-insensitive substring), e.g. 'Garmin CIRQA'"),
|
|
81
|
+
postType: z.enum(["post", "reel", "short", "story", "thread"]).optional().describe("Filter by post type"),
|
|
82
|
+
search: z.string().optional().describe("Free-text search across content, thread segments and label"),
|
|
83
|
+
sort: z.enum(["newest", "oldest", "scheduled"]).optional().describe("Sort order: newest (default), oldest, or scheduled (soonest scheduled first)"),
|
|
84
|
+
page: z.number().optional().describe("Page number (default: 1)"),
|
|
85
|
+
limit: z.number().optional().describe("Posts per page (default: 20, max: 100)"),
|
|
86
|
+
}, async ({ status, platform, campaign, label, postType, search, sort, page, limit }) => {
|
|
87
|
+
const params = new URLSearchParams();
|
|
88
|
+
if (status)
|
|
89
|
+
params.set("status", status);
|
|
90
|
+
if (platform)
|
|
91
|
+
params.set("platform", platform);
|
|
92
|
+
if (campaign)
|
|
93
|
+
params.set("campaign", campaign);
|
|
94
|
+
if (label)
|
|
95
|
+
params.set("label", label);
|
|
96
|
+
if (postType)
|
|
97
|
+
params.set("postType", postType);
|
|
98
|
+
if (search)
|
|
99
|
+
params.set("search", search);
|
|
100
|
+
if (sort)
|
|
101
|
+
params.set("sort", sort);
|
|
102
|
+
if (page)
|
|
103
|
+
params.set("page", String(page));
|
|
104
|
+
if (limit)
|
|
105
|
+
params.set("limit", String(limit));
|
|
106
|
+
const data = await api(`/posts?${params}`);
|
|
107
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
108
|
+
});
|
|
109
|
+
server.tool("get_post", "Get details of a specific post by ID.", {
|
|
110
|
+
id: z.string().describe("Post ID"),
|
|
111
|
+
}, async ({ id }) => {
|
|
112
|
+
const data = await api(`/posts/${id}`);
|
|
113
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
114
|
+
});
|
|
115
|
+
server.tool("create_post", "Create a new social media post. Saves as draft or schedules it for a specific time.", {
|
|
116
|
+
content: z.string().describe("Post text content"),
|
|
117
|
+
connectionIds: z.array(z.string()).describe("Array of social connection IDs to post to. Use list_connections or list_account_groups to find IDs."),
|
|
118
|
+
platformContent: z.record(z.string(), z.string()).optional().describe("Per-platform content overrides, e.g. { twitter: 'shorter version', linkedin: 'longer version' }"),
|
|
119
|
+
platformSettings: z.record(z.string(), z.any()).optional().describe("Per-platform settings, e.g. { instagram: { isReel: true }, youtube: { title: '...', visibility: 'public' } }"),
|
|
120
|
+
postType: z.enum(["post", "reel", "short", "story", "thread"]).optional().describe("Explicit post type (clearer than platformSettings flags, and takes precedence over them). 'reel' needs Instagram/Facebook; 'short' needs YouTube/Instagram/TikTok/Facebook; 'story' needs Instagram/Facebook; 'thread' is X/Twitter-only and requires threadSegments. Defaults to 'post', or is inferred from platformSettings flags (e.g. { instagram: { isReel: true } } → reel). NOTE: a carousel is NOT a postType — attach media with type 'carousel' or use generate_carousel/render_carousel."),
|
|
121
|
+
threadSegments: z.array(z.string()).optional().describe("Ordered tweet segments for a thread (only used when postType='thread', X/Twitter-only). 2–25 segments, each 1–280 characters."),
|
|
122
|
+
label: z.string().optional().describe("Internal working name shown in the PlanPost dashboard list, e.g. 'Garmin CIRQA - IG Carousel'. Never published to any platform — it just makes a campaign's drafts tellable apart. Highly recommended when creating several posts at once."),
|
|
123
|
+
campaign: z.string().optional().describe("Campaign tag, e.g. 'garmin-cirqa-2026' (slugified automatically). Sets utm.campaign and derives a utm.content token from the format (e.g. 'ig-carousel', 'fb-static'). Group/filter/bulk-delete by this with list_campaigns, list_posts({campaign}) and delete_posts({campaign})."),
|
|
124
|
+
schedule: z.string().optional().describe("Natural-language schedule, easier and safer than hand-building ISO: 'now', 'draft', 'today 20:30 Europe/Warsaw', 'tomorrow 07:00', 'in 2 hours', 'next week'. Takes precedence over scheduledFor. A timezone may be appended to wall-clock forms; otherwise `timezone` (or UTC) applies."),
|
|
125
|
+
scheduledFor: z.string().optional().describe("ISO 8601 datetime to schedule the post (e.g. '2026-04-10T14:00:00Z'). Omit for draft. Prefer `schedule` for anything other than an exact instant."),
|
|
126
|
+
timezone: z.string().optional().describe("Timezone for scheduling (default: UTC)"),
|
|
127
|
+
media: z.array(z.object({
|
|
128
|
+
id: z.string(),
|
|
129
|
+
type: z.enum(["image", "video"]),
|
|
130
|
+
url: z.string(),
|
|
131
|
+
key: z.string(),
|
|
132
|
+
filename: z.string(),
|
|
133
|
+
mimeType: z.string(),
|
|
134
|
+
size: z.number(),
|
|
135
|
+
})).optional().describe("Media attachments shared across every platform (use upload_media first to get URLs). Override per-platform via platformMedia."),
|
|
136
|
+
platformMedia: z.record(z.string(), z.array(z.object({
|
|
137
|
+
id: z.string(),
|
|
138
|
+
type: z.enum(["image", "video"]),
|
|
139
|
+
url: z.string(),
|
|
140
|
+
key: z.string(),
|
|
141
|
+
filename: z.string(),
|
|
142
|
+
mimeType: z.string(),
|
|
143
|
+
size: z.number(),
|
|
144
|
+
}))).optional().describe("Per-platform media overrides, e.g. { instagram: [<image>], twitter: [] } — instagram gets the image, twitter publishes with no media. Absent keys fall back to the shared `media` array. Empty `[]` is a meaningful 'no media on this platform'."),
|
|
145
|
+
utm: z.object({
|
|
146
|
+
source: z.string().optional(),
|
|
147
|
+
medium: z.string().optional(),
|
|
148
|
+
campaign: z.string().optional(),
|
|
149
|
+
content: z.string().optional(),
|
|
150
|
+
}).optional().describe("Per-post UTM tag overrides applied when attribution mode is utm. Each field can include the `{platform}` placeholder (substituted per platform at publish time). Letters, digits, `-`, `_`, `.` only. Any field omitted falls back to the user's UTM defaults configured in Settings, then to a hardcoded fallback. Example: { campaign: \"summer-2026\", content: \"weekly-tips\" }."),
|
|
151
|
+
}, async ({ content, connectionIds, platformContent, platformSettings, postType, threadSegments, label, campaign, schedule, scheduledFor, timezone, media, platformMedia, utm }) => {
|
|
152
|
+
const body = { content, connectionIds };
|
|
153
|
+
if (platformContent)
|
|
154
|
+
body.platformContent = platformContent;
|
|
155
|
+
if (platformSettings)
|
|
156
|
+
body.platformSettings = platformSettings;
|
|
157
|
+
if (postType)
|
|
158
|
+
body.postType = postType;
|
|
159
|
+
if (threadSegments)
|
|
160
|
+
body.threadSegments = threadSegments;
|
|
161
|
+
if (label)
|
|
162
|
+
body.label = label;
|
|
163
|
+
if (campaign)
|
|
164
|
+
body.campaign = campaign;
|
|
165
|
+
if (schedule)
|
|
166
|
+
body.schedule = schedule;
|
|
167
|
+
if (scheduledFor)
|
|
168
|
+
body.scheduledFor = scheduledFor;
|
|
169
|
+
if (timezone)
|
|
170
|
+
body.timezone = timezone;
|
|
171
|
+
if (media)
|
|
172
|
+
body.media = media;
|
|
173
|
+
if (platformMedia)
|
|
174
|
+
body.platformMedia = platformMedia;
|
|
175
|
+
if (utm)
|
|
176
|
+
body.utm = utm;
|
|
177
|
+
const data = await api("/posts", { method: "POST", body: JSON.stringify(body) });
|
|
178
|
+
const post = data.data;
|
|
179
|
+
const status = post.status === "published" || post.status === "processing"
|
|
180
|
+
? "Publishing now"
|
|
181
|
+
: post.scheduledFor
|
|
182
|
+
? `Scheduled for ${post.scheduledFor}`
|
|
183
|
+
: "Saved as draft";
|
|
184
|
+
return { content: [{ type: "text", text: `Post created (${status}).${formatWarnings(data.warnings)}${formatLinks(data.links)}\n\n${JSON.stringify(post, null, 2)}` }] };
|
|
185
|
+
});
|
|
186
|
+
server.tool("preview_post", "Show exactly what each platform will receive for a post: the body after platformContent overrides, the attachments after platformMedia overrides, character count vs the platform limit, whether links are clickable, and the warnings for that platform. Use this to check a post (especially carousels, videos and multi-platform posts) BEFORE scheduling or publishing. Returns structured data plus a dashboard link for the visual preview — it does not return screenshots.", {
|
|
187
|
+
id: z.string().describe("Post ID to preview"),
|
|
188
|
+
}, async ({ id }) => {
|
|
189
|
+
const data = await api(`/posts/${id}/preview`);
|
|
190
|
+
const p = data.data;
|
|
191
|
+
const blocks = (p.platforms ?? []).map((pl) => {
|
|
192
|
+
const limit = pl.characterLimit ? `${pl.characterCount}/${pl.characterLimit}${pl.withinLimit ? "" : " ⚠️ OVER LIMIT"}` : `${pl.characterCount} chars`;
|
|
193
|
+
const media = pl.mediaCount === 0
|
|
194
|
+
? `no media${pl.mediaSource === "override" ? " (text-only override)" : ""}`
|
|
195
|
+
: `${pl.mediaCount} ${pl.mediaCount === 1 ? "item" : "items"} (${pl.media.map((m) => m.filename ?? m.type).join(", ")})`;
|
|
196
|
+
const warn = pl.warnings?.length ? `\n warnings: ${pl.warnings.map((w) => w.code).join(", ")}` : "";
|
|
197
|
+
return [
|
|
198
|
+
`── ${pl.platform.toUpperCase()} ─────────────`,
|
|
199
|
+
` text (${pl.contentSource}): ${limit}${pl.linksClickable ? "" : " [links NOT clickable]"}`,
|
|
200
|
+
` media (${pl.mediaSource}): ${media}`,
|
|
201
|
+
pl.threadSegments ? ` thread: ${pl.threadSegments.length} segment(s)` : "",
|
|
202
|
+
warn,
|
|
203
|
+
"",
|
|
204
|
+
pl.content.length > 400 ? `${pl.content.slice(0, 400)}…` : pl.content,
|
|
205
|
+
].filter(Boolean).join("\n");
|
|
206
|
+
});
|
|
207
|
+
const header = `Preview — postType=${p.postType}${p.label ? ` · ${p.label}` : ""}${p.campaign ? ` · campaign=${p.campaign}` : ""}`;
|
|
208
|
+
return {
|
|
209
|
+
content: [{
|
|
210
|
+
type: "text",
|
|
211
|
+
text: `${header}\n\n${blocks.join("\n\n")}${formatLinks(data.links)}\n\n${JSON.stringify(p, null, 2)}`,
|
|
212
|
+
}],
|
|
213
|
+
};
|
|
214
|
+
});
|
|
215
|
+
server.tool("bulk_create_posts", "Create a whole campaign of posts from one manifest. Each entry accepts the same fields as create_post; `defaults` supplies values shared by every entry (campaign, connectionIds, timezone, ...) so you don't repeat them. Rows are independent — one bad row fails alone and the rest still get created. Max 50 per call. Returns a per-row result with ids, warnings and edit links.", {
|
|
216
|
+
posts: z.array(z.record(z.string(), z.any())).describe("Array of post payloads. Each accepts every create_post field: content, connectionIds, platformContent, platformSettings, postType, threadSegments, label, campaign, schedule/scheduledFor, media, platformMedia, utm."),
|
|
217
|
+
defaults: z.record(z.string(), z.any()).optional().describe("Fields merged into every entry before its own values (which always win). Ideal for campaign, connectionIds and timezone."),
|
|
218
|
+
}, async ({ posts, defaults }) => {
|
|
219
|
+
const body = { posts };
|
|
220
|
+
if (defaults)
|
|
221
|
+
body.defaults = defaults;
|
|
222
|
+
const data = await api("/posts/bulk", { method: "POST", body: JSON.stringify(body) });
|
|
223
|
+
const d = data.data;
|
|
224
|
+
const lines = (d.results ?? []).map((r) => r.ok
|
|
225
|
+
? ` #${r.index} OK ${r.label ?? "(no label)"} → ${r.id} [${r.status}]${Array.isArray(r.warnings) && r.warnings.length ? ` (${r.warnings.length} warning(s))` : ""}`
|
|
226
|
+
: ` #${r.index} FAILED ${r.error}`);
|
|
227
|
+
return {
|
|
228
|
+
content: [{
|
|
229
|
+
type: "text",
|
|
230
|
+
text: `Created ${d.created}/${d.total} post(s)${d.failed ? `, ${d.failed} failed` : ""}.\n${lines.join("\n")}\n\n${JSON.stringify(d, null, 2)}`,
|
|
231
|
+
}],
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
server.tool("list_campaigns", "List campaign bundles — every campaign tag in use, with post counts by status, the platforms involved and the next scheduled time. Use this to see what a campaign contains before editing or deleting it.", {}, async () => {
|
|
235
|
+
const data = await api("/posts/campaigns");
|
|
236
|
+
const rows = (data.data ?? []).map((c) => ` ${c.campaign}: ${c.total} post(s) [draft ${c.byStatus.draft}, scheduled ${c.byStatus.scheduled}, published ${c.byStatus.published}, failed ${c.byStatus.failed}] on ${c.platforms.join(", ") || "—"}`);
|
|
237
|
+
return { content: [{ type: "text", text: `${rows.length} campaign(s):\n${rows.join("\n")}\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
238
|
+
});
|
|
239
|
+
server.tool("delete_posts", "Bulk-delete posts matching a filter — e.g. every draft in a campaign. SAFETY: a filter is required (it will never delete everything), published and processing posts are always excluded, and it runs as a DRY RUN unless confirm:true. Always call once without confirm to see what matches, show the user, then confirm.", {
|
|
240
|
+
campaign: z.string().optional().describe("Delete posts tagged with this campaign, e.g. 'garmin-cirqa-2026'"),
|
|
241
|
+
label: z.string().optional().describe("Delete posts whose internal label matches (case-insensitive substring)"),
|
|
242
|
+
ids: z.array(z.string()).optional().describe("Explicit post IDs to delete"),
|
|
243
|
+
status: z.enum(["draft", "scheduled", "failed", "cancelled", "needs_approval"]).optional().describe("Restrict to one status, e.g. 'draft'. Defaults to everything except published/processing."),
|
|
244
|
+
confirm: z.boolean().optional().describe("false/omitted = dry run listing what WOULD be deleted. true = actually delete. Always dry-run first."),
|
|
245
|
+
}, async ({ campaign, label, ids, status, confirm }) => {
|
|
246
|
+
const body = {};
|
|
247
|
+
if (campaign)
|
|
248
|
+
body.campaign = campaign;
|
|
249
|
+
if (label)
|
|
250
|
+
body.label = label;
|
|
251
|
+
if (ids)
|
|
252
|
+
body.ids = ids;
|
|
253
|
+
if (status)
|
|
254
|
+
body.status = status;
|
|
255
|
+
if (confirm)
|
|
256
|
+
body.confirm = true;
|
|
257
|
+
const data = await api("/posts/bulk-delete", { method: "POST", body: JSON.stringify(body) });
|
|
258
|
+
const d = data.data;
|
|
259
|
+
if (d.dryRun) {
|
|
260
|
+
const rows = (d.posts ?? []).map((p) => ` ${p.id} [${p.status}] ${p.label ?? ""} ${p.campaign ? `(${p.campaign})` : ""}`);
|
|
261
|
+
return { content: [{ type: "text", text: `DRY RUN — ${d.matched} post(s) would be deleted:\n${rows.join("\n")}\n\nRe-run with confirm:true to delete.` }] };
|
|
262
|
+
}
|
|
263
|
+
return { content: [{ type: "text", text: `Deleted ${d.deleted} of ${d.matched} matched post(s).` }] };
|
|
264
|
+
});
|
|
265
|
+
server.tool("list_media", "List media you already uploaded, so you can reuse an asset instead of uploading it again. Each item comes back already shaped for create_post's `media` array — pass it straight through.", {
|
|
266
|
+
type: z.enum(["image", "video"]).optional().describe("Filter by media type"),
|
|
267
|
+
search: z.string().optional().describe("Filter by filename (case-insensitive substring), e.g. 'cirqa'"),
|
|
268
|
+
page: z.number().optional().describe("Page number (default: 1)"),
|
|
269
|
+
limit: z.number().optional().describe("Items per page (default: 20, max: 100)"),
|
|
270
|
+
}, async ({ type, search, page, limit }) => {
|
|
271
|
+
const params = new URLSearchParams();
|
|
272
|
+
if (type)
|
|
273
|
+
params.set("type", type);
|
|
274
|
+
if (search)
|
|
275
|
+
params.set("search", search);
|
|
276
|
+
if (page)
|
|
277
|
+
params.set("page", String(page));
|
|
278
|
+
if (limit)
|
|
279
|
+
params.set("limit", String(limit));
|
|
280
|
+
const data = await api(`/media?${params}`);
|
|
281
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
282
|
+
});
|
|
283
|
+
server.tool("update_post", "Update a draft or scheduled post.", {
|
|
284
|
+
id: z.string().describe("Post ID to update"),
|
|
285
|
+
content: z.string().optional().describe("New post text content"),
|
|
286
|
+
connectionIds: z.array(z.string()).optional().describe("New connection IDs"),
|
|
287
|
+
platformContent: z.record(z.string(), z.string()).optional().describe("Per-platform content overrides"),
|
|
288
|
+
platformSettings: z.record(z.string(), z.any()).optional().describe("Per-platform settings (e.g. { instagram: { isReel: true } })"),
|
|
289
|
+
postType: z.enum(["post", "reel", "short", "story", "thread"]).optional().describe("Explicit post type; takes precedence over platformSettings flags. Same platform rules as create_post ('thread' is X/Twitter-only and needs threadSegments). Omit to leave the current type unchanged — a plain edit never downgrades a reel/short/story."),
|
|
290
|
+
threadSegments: z.array(z.string()).optional().describe("Ordered tweet segments for a thread (postType='thread'). 2–25 segments, each 1–280 characters."),
|
|
291
|
+
label: z.string().nullable().optional().describe("Internal working name shown in the dashboard. Pass null to clear. Never published."),
|
|
292
|
+
campaign: z.string().nullable().optional().describe("Re-tag this post's campaign (sets utm.campaign). Pass null to clear."),
|
|
293
|
+
schedule: z.string().optional().describe("Natural-language reschedule: 'draft' (unschedule), 'today 20:30 Europe/Warsaw', 'tomorrow 07:00', 'in 2 hours', 'next week same time' (anchors on the post's current scheduled time). Use publish_post to publish immediately — 'now' is rejected here."),
|
|
294
|
+
media: z.array(z.object({
|
|
295
|
+
id: z.string(),
|
|
296
|
+
type: z.enum(["image", "video"]),
|
|
297
|
+
url: z.string(),
|
|
298
|
+
key: z.string(),
|
|
299
|
+
filename: z.string(),
|
|
300
|
+
mimeType: z.string(),
|
|
301
|
+
size: z.number(),
|
|
302
|
+
})).optional().describe("Replace the shared media array. Omit to leave existing media alone."),
|
|
303
|
+
platformMedia: z.record(z.string(), z.array(z.object({
|
|
304
|
+
id: z.string(),
|
|
305
|
+
type: z.enum(["image", "video"]),
|
|
306
|
+
url: z.string(),
|
|
307
|
+
key: z.string(),
|
|
308
|
+
filename: z.string(),
|
|
309
|
+
mimeType: z.string(),
|
|
310
|
+
size: z.number(),
|
|
311
|
+
}))).nullable().optional().describe("Per-platform media overrides. Pass `null` to clear all overrides (every platform uses the shared `media` array). Omit to leave overrides unchanged."),
|
|
312
|
+
utm: z.object({
|
|
313
|
+
source: z.string().optional(),
|
|
314
|
+
medium: z.string().optional(),
|
|
315
|
+
campaign: z.string().optional(),
|
|
316
|
+
content: z.string().optional(),
|
|
317
|
+
}).nullable().optional().describe("Per-post UTM overrides. Pass `null` to clear all overrides (use user defaults). Omit to leave alone."),
|
|
318
|
+
scheduledFor: z.string().optional().describe("New schedule time (ISO 8601), or empty string to unschedule"),
|
|
319
|
+
timezone: z.string().optional().describe("Timezone"),
|
|
320
|
+
}, async ({ id, content, connectionIds, platformContent, platformSettings, postType, threadSegments, label, campaign, schedule, media, platformMedia, utm, scheduledFor, timezone }) => {
|
|
321
|
+
const body = {};
|
|
322
|
+
if (content !== undefined)
|
|
323
|
+
body.content = content;
|
|
324
|
+
if (connectionIds !== undefined)
|
|
325
|
+
body.connectionIds = connectionIds;
|
|
326
|
+
if (platformContent !== undefined)
|
|
327
|
+
body.platformContent = platformContent;
|
|
328
|
+
if (platformSettings !== undefined)
|
|
329
|
+
body.platformSettings = platformSettings;
|
|
330
|
+
if (postType !== undefined)
|
|
331
|
+
body.postType = postType;
|
|
332
|
+
if (threadSegments !== undefined)
|
|
333
|
+
body.threadSegments = threadSegments;
|
|
334
|
+
if (label !== undefined)
|
|
335
|
+
body.label = label;
|
|
336
|
+
if (campaign !== undefined)
|
|
337
|
+
body.campaign = campaign;
|
|
338
|
+
if (schedule !== undefined)
|
|
339
|
+
body.schedule = schedule;
|
|
340
|
+
if (media !== undefined)
|
|
341
|
+
body.media = media;
|
|
342
|
+
if (platformMedia !== undefined)
|
|
343
|
+
body.platformMedia = platformMedia;
|
|
344
|
+
if (utm !== undefined)
|
|
345
|
+
body.utm = utm;
|
|
346
|
+
if (scheduledFor !== undefined)
|
|
347
|
+
body.scheduledFor = scheduledFor;
|
|
348
|
+
if (timezone !== undefined)
|
|
349
|
+
body.timezone = timezone;
|
|
350
|
+
const data = await api(`/posts/${id}`, { method: "PUT", body: JSON.stringify(body) });
|
|
351
|
+
return { content: [{ type: "text", text: `Post updated.${formatWarnings(data.warnings)}${formatLinks(data.links)}\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
352
|
+
});
|
|
353
|
+
server.tool("delete_post", "Delete a post.", {
|
|
354
|
+
id: z.string().describe("Post ID to delete"),
|
|
355
|
+
}, async ({ id }) => {
|
|
356
|
+
await api(`/posts/${id}`, { method: "DELETE" });
|
|
357
|
+
return { content: [{ type: "text", text: `Post ${id} deleted.` }] };
|
|
358
|
+
});
|
|
359
|
+
server.tool("publish_post", "Publish a draft or scheduled post immediately to all connected platforms.", {
|
|
360
|
+
id: z.string().describe("Post ID to publish now"),
|
|
361
|
+
}, async ({ id }) => {
|
|
362
|
+
const data = await api(`/posts/${id}/publish`, { method: "POST" });
|
|
363
|
+
return { content: [{ type: "text", text: `Post is being published.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
364
|
+
});
|
|
365
|
+
server.tool("list_connections", "List all connected social media accounts (Instagram, Twitter, LinkedIn, etc.).", {}, async () => {
|
|
366
|
+
const data = await api("/connections");
|
|
367
|
+
const connections = data.data;
|
|
368
|
+
const summary = connections.map((c) => `${c.provider} — ${c.metadata?.accountName || c.providerUsername} (ID: ${c.id})`).join("\n");
|
|
369
|
+
return { content: [{ type: "text", text: `${connections.length} connected accounts:\n\n${summary}\n\nFull data:\n${JSON.stringify(connections, null, 2)}` }] };
|
|
370
|
+
});
|
|
371
|
+
server.tool("list_account_groups", "List account groups the user has set up in PlanPost (e.g. 'Polish Pages', 'English Pages'). Each group bundles social connections so you can target multiple accounts in one create_post call. Returns id, name, connectionCount, isDefault, and the underlying connections.", {}, async () => {
|
|
372
|
+
const data = await api("/account-groups");
|
|
373
|
+
const groups = data.data;
|
|
374
|
+
if (!groups || groups.length === 0) {
|
|
375
|
+
return { content: [{ type: "text", text: "No account groups configured. Targets must be specific connectionIds (see list_connections)." }] };
|
|
376
|
+
}
|
|
377
|
+
const summary = groups.map((g) => `${g.isDefault ? "★ " : " "}${g.name} (${g.connectionCount} accounts, id: ${g.id})`).join("\n");
|
|
378
|
+
return { content: [{ type: "text", text: `${groups.length} account groups:\n\n${summary}\n\nFull data:\n${JSON.stringify(groups, null, 2)}` }] };
|
|
379
|
+
});
|
|
380
|
+
server.tool("get_account_group", "Fetch one account group by id, name, or default flag. Returns the group's connectionIds[] so they can be passed straight to create_post. Name matching is case-insensitive (exact match preferred, substring fallback). If the name is ambiguous the server returns 300 with the candidates.", {
|
|
381
|
+
idOrName: z.string().optional().describe("Mongo ObjectId or group name (e.g. 'Polish Pages'). Omit when using default=true."),
|
|
382
|
+
default: z.boolean().optional().describe("When true, fetch the team's default group and ignore idOrName."),
|
|
383
|
+
}, async ({ idOrName, default: useDefault }) => {
|
|
384
|
+
if (!useDefault && !idOrName) {
|
|
385
|
+
throw new Error("Provide idOrName or set default=true.");
|
|
386
|
+
}
|
|
387
|
+
const path = useDefault ? "/account-groups/default" : `/account-groups/${encodeURIComponent(idOrName)}`;
|
|
388
|
+
const data = await api(path);
|
|
389
|
+
const group = data.data;
|
|
390
|
+
const connectionIds = (group.connections || []).map((c) => c.id);
|
|
391
|
+
return {
|
|
392
|
+
content: [{
|
|
393
|
+
type: "text",
|
|
394
|
+
text: `Group: ${group.name}${group.isDefault ? " (default)" : ""}\nConnections (${group.connectionCount}): ${connectionIds.join(", ")}\n\nFull data:\n${JSON.stringify(group, null, 2)}`,
|
|
395
|
+
}],
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
server.tool("create_account_group", "Create a named account group (e.g. 'Polish Pages') bundling social connections, so future posts can target the whole set at once.", {
|
|
399
|
+
name: z.string().describe("Group name"),
|
|
400
|
+
description: z.string().optional(),
|
|
401
|
+
color: z.string().optional().describe("Display color (hex)"),
|
|
402
|
+
connectionIds: z.array(z.string()).optional().describe("Social connection IDs to include (see list_connections)"),
|
|
403
|
+
isDefault: z.boolean().optional().describe("Make this the team's default group"),
|
|
404
|
+
}, async ({ name, description, color, connectionIds, isDefault }) => {
|
|
405
|
+
const body = { name };
|
|
406
|
+
if (description)
|
|
407
|
+
body.description = description;
|
|
408
|
+
if (color)
|
|
409
|
+
body.color = color;
|
|
410
|
+
if (connectionIds)
|
|
411
|
+
body.connectionIds = connectionIds;
|
|
412
|
+
if (isDefault !== undefined)
|
|
413
|
+
body.isDefault = isDefault;
|
|
414
|
+
const data = await api("/account-groups", { method: "POST", body: JSON.stringify(body) });
|
|
415
|
+
return { content: [{ type: "text", text: `Group created.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
416
|
+
});
|
|
417
|
+
server.tool("update_account_group", "Update an account group's name, description, color, default flag, or replace its full connection list.", {
|
|
418
|
+
id: z.string().describe("Group ID"),
|
|
419
|
+
name: z.string().optional(),
|
|
420
|
+
description: z.string().optional(),
|
|
421
|
+
color: z.string().optional(),
|
|
422
|
+
isDefault: z.boolean().optional(),
|
|
423
|
+
connectionIds: z.array(z.string()).optional().describe("REPLACES the group's connections when provided"),
|
|
424
|
+
}, async ({ id, ...rest }) => {
|
|
425
|
+
const body = {};
|
|
426
|
+
for (const [k, v] of Object.entries(rest))
|
|
427
|
+
if (v !== undefined)
|
|
428
|
+
body[k] = v;
|
|
429
|
+
const data = await api(`/account-groups/${id}`, { method: "PATCH", body: JSON.stringify(body) });
|
|
430
|
+
return { content: [{ type: "text", text: `Group updated.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
431
|
+
});
|
|
432
|
+
server.tool("delete_account_group", "Delete an account group (does not disconnect the underlying social accounts).", {
|
|
433
|
+
id: z.string().describe("Group ID"),
|
|
434
|
+
}, async ({ id }) => {
|
|
435
|
+
await api(`/account-groups/${id}`, { method: "DELETE" });
|
|
436
|
+
return { content: [{ type: "text", text: `Group ${id} deleted.` }] };
|
|
437
|
+
});
|
|
438
|
+
server.tool("add_connections_to_group", "Add social connections to an existing account group.", {
|
|
439
|
+
id: z.string().describe("Group ID"),
|
|
440
|
+
connectionIds: z.array(z.string()).describe("Connection IDs to add"),
|
|
441
|
+
}, async ({ id, connectionIds }) => {
|
|
442
|
+
const data = await api(`/account-groups/${id}/connections`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
body: JSON.stringify({ connectionIds }),
|
|
445
|
+
});
|
|
446
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
447
|
+
});
|
|
448
|
+
server.tool("remove_connections_from_group", "Remove social connections from an account group.", {
|
|
449
|
+
id: z.string().describe("Group ID"),
|
|
450
|
+
connectionIds: z.array(z.string()).describe("Connection IDs to remove"),
|
|
451
|
+
}, async ({ id, connectionIds }) => {
|
|
452
|
+
const data = await api(`/account-groups/${id}/connections`, {
|
|
453
|
+
method: "DELETE",
|
|
454
|
+
body: JSON.stringify({ connectionIds }),
|
|
455
|
+
});
|
|
456
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
457
|
+
});
|
|
458
|
+
server.tool("get_connection_link", "Generate a link the user can open in their browser to connect a new social account (OAuth needs a human — send them this link).", {
|
|
459
|
+
provider: z.enum(["instagram", "facebook", "twitter", "linkedin", "youtube", "tiktok", "threads"]).describe("Platform to connect"),
|
|
460
|
+
}, async ({ provider }) => {
|
|
461
|
+
const data = await api(`/connections/connect-link?provider=${provider}`);
|
|
462
|
+
return { content: [{ type: "text", text: `${data.data.url}\n\n${data.data.instructions}` }] };
|
|
463
|
+
});
|
|
464
|
+
server.tool("submit_for_approval", "Submit a post for human approval. Use this when your API key requires approval (or whenever you want a human to sign off before publishing). Returns an approve link and an approval ID to poll. On approval the post auto-schedules if it has a scheduledFor time.", {
|
|
465
|
+
postId: z.string().describe("Post ID to submit (create it first with create_post)"),
|
|
466
|
+
reviewerEmail: z.string().optional().describe("E-mail the approve link to a specific reviewer (e.g. the client). Omit to notify the team's approvers in-app."),
|
|
467
|
+
expiresInDays: z.number().optional().describe("Auto-expire the approval link after N days"),
|
|
468
|
+
supersede: z.boolean().optional().describe("If the post already has a pending approval, replace it (default false → returns 409)"),
|
|
469
|
+
}, async ({ postId, reviewerEmail, expiresInDays, supersede }) => {
|
|
470
|
+
const body = { postId, autoPublishOnApprove: true };
|
|
471
|
+
if (reviewerEmail)
|
|
472
|
+
body.reviewerEmail = reviewerEmail;
|
|
473
|
+
if (expiresInDays)
|
|
474
|
+
body.expiresInDays = expiresInDays;
|
|
475
|
+
if (supersede !== undefined)
|
|
476
|
+
body.supersede = supersede;
|
|
477
|
+
const data = await api("/approvals", { method: "POST", body: JSON.stringify(body) });
|
|
478
|
+
const d = data.data;
|
|
479
|
+
return { content: [{ type: "text", text: `Submitted for approval.\nApprove link: ${d.approveUrl}\nApproval ID: ${d.id} (poll with get_approval_status)\n\n${JSON.stringify(d, null, 2)}` }] };
|
|
480
|
+
});
|
|
481
|
+
server.tool("list_pending_approvals", "List posts waiting for approval (and their outcomes). Filter by status to build a review queue.", {
|
|
482
|
+
status: z.string().optional().describe("Comma-separated: pending, approved, rejected, changes_requested, expired, revoked. Default: all."),
|
|
483
|
+
}, async ({ status }) => {
|
|
484
|
+
const params = new URLSearchParams();
|
|
485
|
+
if (status)
|
|
486
|
+
params.set("status", status);
|
|
487
|
+
const data = await api(`/approvals?${params}`);
|
|
488
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
489
|
+
});
|
|
490
|
+
server.tool("get_approval_status", "Check whether a human has decided on a submitted approval yet (pending / approved / rejected / changes_requested). Poll this after submit_for_approval.", {
|
|
491
|
+
approvalId: z.string().describe("Approval ID from submit_for_approval"),
|
|
492
|
+
}, async ({ approvalId }) => {
|
|
493
|
+
const data = await api(`/approvals/${approvalId}`);
|
|
494
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
495
|
+
});
|
|
496
|
+
server.tool("get_analytics", "Get post performance analytics — impressions, likes, comments, shares.", {
|
|
497
|
+
platform: z.string().optional().describe("Filter by platform"),
|
|
498
|
+
startDate: z.string().optional().describe("Start date (ISO 8601)"),
|
|
499
|
+
endDate: z.string().optional().describe("End date (ISO 8601)"),
|
|
500
|
+
page: z.number().optional().describe("Page number"),
|
|
501
|
+
limit: z.number().optional().describe("Results per page"),
|
|
502
|
+
}, async ({ platform, startDate, endDate, page, limit }) => {
|
|
503
|
+
const params = new URLSearchParams();
|
|
504
|
+
if (platform)
|
|
505
|
+
params.set("platform", platform);
|
|
506
|
+
if (startDate)
|
|
507
|
+
params.set("startDate", startDate);
|
|
508
|
+
if (endDate)
|
|
509
|
+
params.set("endDate", endDate);
|
|
510
|
+
if (page)
|
|
511
|
+
params.set("page", String(page));
|
|
512
|
+
if (limit)
|
|
513
|
+
params.set("limit", String(limit));
|
|
514
|
+
const data = await api(`/analytics?${params}`);
|
|
515
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
516
|
+
});
|
|
517
|
+
server.tool("upload_media", "Get a presigned URL to upload an image or video for use in posts.", {
|
|
518
|
+
filename: z.string().describe("File name (e.g. 'photo.jpg')"),
|
|
519
|
+
contentType: z.string().describe("MIME type (e.g. 'image/jpeg', 'video/mp4')"),
|
|
520
|
+
size: z.number().describe("File size in bytes"),
|
|
521
|
+
}, async ({ filename, contentType, size }) => {
|
|
522
|
+
const data = await api("/media/upload-url", {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body: JSON.stringify({ filename, contentType, size }),
|
|
525
|
+
});
|
|
526
|
+
return { content: [{ type: "text", text: `Upload URL generated. Upload your file with PUT to the URL below, then use the key/publicUrl in create_post media array.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
527
|
+
});
|
|
528
|
+
server.tool("repurpose_content", "Save AI-repurposed content as draft posts. Provide an array of posts with content and target connections.", {
|
|
529
|
+
posts: z.array(z.object({
|
|
530
|
+
content: z.string().describe("Repurposed post content"),
|
|
531
|
+
connectionIds: z.array(z.string()).describe("Connection IDs to target"),
|
|
532
|
+
})).describe("Array of repurposed posts to save as drafts"),
|
|
533
|
+
}, async ({ posts }) => {
|
|
534
|
+
const data = await api("/repurpose", {
|
|
535
|
+
method: "POST",
|
|
536
|
+
body: JSON.stringify({ posts }),
|
|
537
|
+
});
|
|
538
|
+
return { content: [{ type: "text", text: `${data.data.savedCount} draft posts saved.\n\n${JSON.stringify(data.data.posts, null, 2)}` }] };
|
|
539
|
+
});
|
|
540
|
+
server.tool("list_video_templates", "List available video templates (quotes, hooks, listicles, stories, product showcases, testimonials, promos). Each template describes its editable fields — use them with create_video.", {
|
|
541
|
+
category: z.string().optional().describe("Filter by template category"),
|
|
542
|
+
}, async ({ category }) => {
|
|
543
|
+
const params = new URLSearchParams();
|
|
544
|
+
if (category)
|
|
545
|
+
params.set("category", category);
|
|
546
|
+
const data = await api(`/videos/templates?${params}`);
|
|
547
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
548
|
+
});
|
|
549
|
+
server.tool("create_video", "Submit a video render job from a template. Returns a render ID — poll get_video_status until status is 'completed', then use the returned video URL as post media.", {
|
|
550
|
+
templateId: z.string().describe("Template ID from list_video_templates"),
|
|
551
|
+
fields: z.record(z.string(), z.any()).describe("Field values for the template, e.g. { quoteText: '...', backgroundColor: '#111' }. Field names come from the template definition."),
|
|
552
|
+
aspectRatio: z.string().optional().describe("Override aspect ratio (e.g. '9:16', '1:1'). Defaults to the template's ratio."),
|
|
553
|
+
}, async ({ templateId, fields, aspectRatio }) => {
|
|
554
|
+
const body = { templateId, fields };
|
|
555
|
+
if (aspectRatio)
|
|
556
|
+
body.aspectRatio = aspectRatio;
|
|
557
|
+
const data = await api("/videos/render", { method: "POST", body: JSON.stringify(body) });
|
|
558
|
+
return { content: [{ type: "text", text: `Render submitted. Poll get_video_status with this ID until completed.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
559
|
+
});
|
|
560
|
+
server.tool("get_video_status", "Check the status of a video render job. Completed renders include presigned download URLs for the video and thumbnail (valid ~1 hour).", {
|
|
561
|
+
id: z.string().describe("Render ID from create_video"),
|
|
562
|
+
}, async ({ id }) => {
|
|
563
|
+
const data = await api(`/videos/render/${id}`);
|
|
564
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
565
|
+
});
|
|
566
|
+
server.tool("generate_carousel", "Generate carousel slide TEXT (title/content/quote/CTA) from long-form content. Then call render_carousel to turn the slides into branded PNG images in the media library.", {
|
|
567
|
+
content: z.string().describe("Source content to decompose into slides (20–10,000 chars)"),
|
|
568
|
+
platform: z.enum(["instagram", "linkedin"]).optional().describe("Platform hint for tone/length (default instagram)"),
|
|
569
|
+
maxSlides: z.number().optional().describe("Max slides, 3–10 (default 5)"),
|
|
570
|
+
style: z.enum(["minimal", "bold", "professional", "playful"]).optional().describe("Copywriting tone hint"),
|
|
571
|
+
brandColors: z.object({
|
|
572
|
+
primary: z.string().optional(),
|
|
573
|
+
secondary: z.string().optional(),
|
|
574
|
+
}).optional().describe("Brand colors as hex strings"),
|
|
575
|
+
}, async ({ content, platform, maxSlides, style, brandColors }) => {
|
|
576
|
+
const body = { content };
|
|
577
|
+
if (platform)
|
|
578
|
+
body.platform = platform;
|
|
579
|
+
if (maxSlides)
|
|
580
|
+
body.maxSlides = maxSlides;
|
|
581
|
+
if (style)
|
|
582
|
+
body.style = style;
|
|
583
|
+
if (brandColors)
|
|
584
|
+
body.brandColors = brandColors;
|
|
585
|
+
const data = await api("/carousels/generate", { method: "POST", body: JSON.stringify(body) });
|
|
586
|
+
return { content: [{ type: "text", text: `Slides generated. Pass them to render_carousel (pick a family/palette via list_carousel_styles) to produce images.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
587
|
+
});
|
|
588
|
+
server.tool("list_carousel_styles", "List the carousel design system: template families (with content-variant layouts), the 40-palette library (with resolved colors), output formats, font pairings, and slide caps. Use the ids with render_carousel.", {}, async () => {
|
|
589
|
+
const data = await api("/carousels/options");
|
|
590
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
591
|
+
});
|
|
592
|
+
server.tool("render_carousel", "Render carousel slides into branded PNG images (server-side, pixel-perfect) saved to the media library. Returns a render ID — poll get_carousel_render until 'completed'. Slide 1 uses the family's cover layout, the last slide its CTA layout, middle slides cycle content variants.", {
|
|
593
|
+
slides: z.array(z.object({
|
|
594
|
+
headline: z.string().describe("Slide headline (≤500 chars)"),
|
|
595
|
+
body: z.string().describe("Slide body (≤2000 chars; may be empty)"),
|
|
596
|
+
type: z.enum(["title", "content", "quote", "cta"]).describe("Slide role — 'title' pins the cover layout, 'cta' the CTA layout"),
|
|
597
|
+
imageUrl: z.string().optional().describe("https image for this slide (used by photo layouts)"),
|
|
598
|
+
})).min(2).max(10).describe("2–10 slides, in order (from generate_carousel or hand-written)"),
|
|
599
|
+
family: z.string().describe("Template family id from list_carousel_styles (e.g. 'bold-announcement', 'tips-listicle', 'photo-story')"),
|
|
600
|
+
format: z.enum(["portrait", "square", "square-lg", "story"]).optional().describe("Output size: portrait 1080×1350 (default, IG/FB), square 1080×1080, square-lg 1200×1200 (LinkedIn), story 1080×1920 (TikTok photo mode)"),
|
|
601
|
+
paletteId: z.string().optional().describe("Palette id from list_carousel_styles (defaults to the family's suggested palette)"),
|
|
602
|
+
brandKitId: z.string().optional().describe("Brand kit id from list_brand_kits — applies its palette, fonts, logo, and handle (explicit values below still win; narrow with brandParts)"),
|
|
603
|
+
brandParts: z.object({
|
|
604
|
+
colors: z.boolean().optional().describe("Apply the kit's palette (default true)"),
|
|
605
|
+
fonts: z.boolean().optional().describe("Apply the kit's font pairing (default true)"),
|
|
606
|
+
logo: z.boolean().optional().describe("Apply the kit's logo (default true)"),
|
|
607
|
+
identity: z.boolean().optional().describe("Apply the kit's @handle / brand name (default true)"),
|
|
608
|
+
}).optional().describe("With brandKitId: which kit parts to apply. Omitted object/keys default to true (apply everything) — set a key to false to skip that part"),
|
|
609
|
+
fontPairingId: z.enum(["impact", "friendly", "editorial", "clean", "geometric"]).optional().describe("Font pairing (defaults to the family's)"),
|
|
610
|
+
fontSize: z.enum(["small", "medium", "large"]).optional().describe("Type scale (default medium)"),
|
|
611
|
+
numbering: z.boolean().optional().describe("Add an 'i/N' position chip to each slide"),
|
|
612
|
+
seamless: z.boolean().optional().describe("One wide mesh background sliced across all slides — swiping reveals a continuous panorama"),
|
|
613
|
+
seamlessSeed: z.number().optional().describe("Seed for the seamless mesh (same seed = same background)"),
|
|
614
|
+
handle: z.string().optional().describe("'@handle' meta text worked into covers and CTAs"),
|
|
615
|
+
logoUrl: z.string().optional().describe("https logo placed bottom-left on every slide"),
|
|
616
|
+
defaultImageUrl: z.string().optional().describe("Fallback https image for photo layouts when a slide has none"),
|
|
617
|
+
}, async ({ slides, family, format, paletteId, brandKitId, brandParts, fontPairingId, fontSize, numbering, seamless, seamlessSeed, handle, logoUrl, defaultImageUrl }) => {
|
|
618
|
+
const settings = {
|
|
619
|
+
version: 2,
|
|
620
|
+
format: format ?? "portrait",
|
|
621
|
+
family,
|
|
622
|
+
fontSize: fontSize ?? "medium",
|
|
623
|
+
};
|
|
624
|
+
if (paletteId)
|
|
625
|
+
settings.paletteId = paletteId;
|
|
626
|
+
if (brandKitId)
|
|
627
|
+
settings.brandKitId = brandKitId;
|
|
628
|
+
if (brandKitId && brandParts)
|
|
629
|
+
settings.brandParts = brandParts;
|
|
630
|
+
if (fontPairingId)
|
|
631
|
+
settings.fontPairingId = fontPairingId;
|
|
632
|
+
if (numbering !== undefined)
|
|
633
|
+
settings.numbering = numbering;
|
|
634
|
+
if (seamless)
|
|
635
|
+
settings.seamless = { enabled: true, ...(seamlessSeed !== undefined ? { seed: seamlessSeed } : {}) };
|
|
636
|
+
if (handle)
|
|
637
|
+
settings.handle = handle;
|
|
638
|
+
if (logoUrl)
|
|
639
|
+
settings.logoUrl = logoUrl;
|
|
640
|
+
if (defaultImageUrl)
|
|
641
|
+
settings.defaultImageUrl = defaultImageUrl;
|
|
642
|
+
const body = {
|
|
643
|
+
slides: slides.map((s, i) => ({ order: i, ...s })),
|
|
644
|
+
settings,
|
|
645
|
+
};
|
|
646
|
+
const data = await api("/carousels/render", { method: "POST", body: JSON.stringify(body) });
|
|
647
|
+
return { content: [{ type: "text", text: `Render submitted (~1-3s per slide). Poll get_carousel_render with this ID until completed.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
648
|
+
});
|
|
649
|
+
server.tool("get_carousel_render", "Check a carousel render job. When completed, `assets` are full media attachments (id/type/url/key/filename/mimeType/size, in slide order) — pass them straight into create_post's `media` array to draft or schedule the carousel post.", {
|
|
650
|
+
id: z.string().describe("Render ID from render_carousel"),
|
|
651
|
+
}, async ({ id }) => {
|
|
652
|
+
const data = await api(`/carousels/render/${id}`);
|
|
653
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
654
|
+
});
|
|
655
|
+
server.tool("list_brand_kits", "List saved brand kits (palette, font pairing, logo, handle). Use a kit's id as render_carousel's brandKitId for one-call branded output.", {}, async () => {
|
|
656
|
+
const data = await api("/brand-kits");
|
|
657
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
658
|
+
});
|
|
659
|
+
server.tool("list_content_sources", "List connected content sources (WordPress sites, RSS feeds) used to import content for repurposing.", {}, async () => {
|
|
660
|
+
const data = await api("/content-sources");
|
|
661
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
662
|
+
});
|
|
663
|
+
server.tool("add_content_source", "Connect a WordPress site or RSS feed as a content source. Imported posts can then be repurposed into social content.", {
|
|
664
|
+
type: z.enum(["wordpress", "rss"]).describe("Source type"),
|
|
665
|
+
name: z.string().describe("Display name for the source"),
|
|
666
|
+
url: z.string().describe("Site URL (WordPress base URL or RSS feed URL)"),
|
|
667
|
+
apiKey: z.string().optional().describe("Optional API key for protected WordPress sites"),
|
|
668
|
+
autoImport: z.boolean().optional().describe("Automatically import new content on the sync schedule"),
|
|
669
|
+
importFrequency: z.enum(["daily", "weekly"]).optional().describe("Auto-import frequency (default weekly)"),
|
|
670
|
+
}, async ({ type, name, url, apiKey, autoImport, importFrequency }) => {
|
|
671
|
+
const body = { type, name, url };
|
|
672
|
+
if (apiKey)
|
|
673
|
+
body.apiKey = apiKey;
|
|
674
|
+
if (autoImport !== undefined || importFrequency) {
|
|
675
|
+
body.settings = { autoImport: autoImport ?? false, importFrequency: importFrequency ?? "weekly" };
|
|
676
|
+
}
|
|
677
|
+
const data = await api("/content-sources", { method: "POST", body: JSON.stringify(body) });
|
|
678
|
+
return { content: [{ type: "text", text: `Content source connected.\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
679
|
+
});
|
|
680
|
+
server.tool("delete_content_source", "Remove a content source.", {
|
|
681
|
+
id: z.string().describe("Content source ID"),
|
|
682
|
+
}, async ({ id }) => {
|
|
683
|
+
await api(`/content-sources/${id}`, { method: "DELETE" });
|
|
684
|
+
return { content: [{ type: "text", text: `Content source ${id} deleted.` }] };
|
|
685
|
+
});
|
|
686
|
+
server.tool("fetch_source_content", "Import the latest content from a source (e.g. new WordPress posts) right now, then list it with list_imported_content.", {
|
|
687
|
+
id: z.string().describe("Content source ID"),
|
|
688
|
+
}, async ({ id }) => {
|
|
689
|
+
const data = await api(`/content-sources/${id}/fetch`, { method: "POST" });
|
|
690
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
691
|
+
});
|
|
692
|
+
server.tool("list_imported_content", "List content already imported from a source — useful as input for repurpose_content or create_post.", {
|
|
693
|
+
id: z.string().describe("Content source ID"),
|
|
694
|
+
}, async ({ id }) => {
|
|
695
|
+
const data = await api(`/content-sources/${id}/content`);
|
|
696
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
697
|
+
});
|
|
698
|
+
server.tool("get_revenue_overview", "Get revenue analytics: totals and trend vs the previous period, recent transactions, and MRR metrics from connected providers (Stripe / RevenueCat / Shopify). Read-only.", {
|
|
699
|
+
days: z.number().optional().describe("Lookback window in days (default 30)"),
|
|
700
|
+
appId: z.string().optional().describe("Scope to a specific app's revenue connections"),
|
|
701
|
+
includeTransactions: z.boolean().optional().describe("Also fetch recent Stripe charges (default false)"),
|
|
702
|
+
includeMrr: z.boolean().optional().describe("Also fetch Stripe/RevenueCat MRR metrics (default false)"),
|
|
703
|
+
}, async ({ days, appId, includeTransactions, includeMrr }) => {
|
|
704
|
+
const params = new URLSearchParams();
|
|
705
|
+
if (days)
|
|
706
|
+
params.set("days", String(days));
|
|
707
|
+
if (appId)
|
|
708
|
+
params.set("appId", appId);
|
|
709
|
+
const overview = await api(`/revenue/overview?${params}`);
|
|
710
|
+
const result = { overview: overview.data };
|
|
711
|
+
if (includeTransactions) {
|
|
712
|
+
const txParams = new URLSearchParams();
|
|
713
|
+
if (appId)
|
|
714
|
+
txParams.set("appId", appId);
|
|
715
|
+
const tx = await api(`/revenue/transactions?${txParams}`).catch((e) => ({ data: { error: e.message } }));
|
|
716
|
+
result.transactions = tx.data;
|
|
717
|
+
}
|
|
718
|
+
if (includeMrr) {
|
|
719
|
+
const mrrParams = new URLSearchParams();
|
|
720
|
+
if (appId)
|
|
721
|
+
mrrParams.set("appId", appId);
|
|
722
|
+
const [stripe, rc] = await Promise.all([
|
|
723
|
+
api(`/revenue/stripe-metrics?${mrrParams}`).catch((e) => ({ data: { error: e.message } })),
|
|
724
|
+
api(`/revenue/rc-metrics?${mrrParams}`).catch((e) => ({ data: { error: e.message } })),
|
|
725
|
+
]);
|
|
726
|
+
result.mrr = { stripe: stripe.data, revenuecat: rc.data };
|
|
727
|
+
}
|
|
728
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
729
|
+
});
|
|
730
|
+
server.tool("generate_image", "Generate an image with AI (DALL-E) for use in posts. Costs 1 AI credit. Returns the image URL — pass it through upload_media/create_post media flow.", {
|
|
731
|
+
prompt: z.string().describe("What to generate (≤4000 chars)"),
|
|
732
|
+
width: z.number().optional(),
|
|
733
|
+
height: z.number().optional(),
|
|
734
|
+
style: z.string().optional().describe("Style hint (e.g. 'photorealistic', 'illustration')"),
|
|
735
|
+
}, async ({ prompt, width, height, style }) => {
|
|
736
|
+
const body = { prompt };
|
|
737
|
+
if (width)
|
|
738
|
+
body.width = width;
|
|
739
|
+
if (height)
|
|
740
|
+
body.height = height;
|
|
741
|
+
if (style)
|
|
742
|
+
body.style = style;
|
|
743
|
+
const data = await api("/images/generate", { method: "POST", body: JSON.stringify(body) });
|
|
744
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
745
|
+
});
|
|
746
|
+
server.tool("get_brand_voices", "List the user's brand voices (tone-of-voice profiles analyzed from their content).", {}, async () => {
|
|
747
|
+
const data = await api("/brand-voice");
|
|
748
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
749
|
+
});
|
|
750
|
+
server.tool("get_brand_voice_prompt", "Get the ready-to-use system prompt for a brand voice — apply it when writing posts so the content matches the user's tone.", {
|
|
751
|
+
id: z.string().describe("Brand voice ID (see get_brand_voices)"),
|
|
752
|
+
}, async ({ id }) => {
|
|
753
|
+
const data = await api(`/brand-voice/${id}/prompt`);
|
|
754
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
755
|
+
});
|
|
756
|
+
server.tool("get_best_times", "Get the best times to post per platform, based on the user's engagement history. Use before picking scheduledFor in create_post.", {
|
|
757
|
+
platform: z.string().optional().describe("Limit to one platform (e.g. 'instagram')"),
|
|
758
|
+
}, async ({ platform }) => {
|
|
759
|
+
const params = new URLSearchParams();
|
|
760
|
+
if (platform)
|
|
761
|
+
params.set("platform", platform);
|
|
762
|
+
const data = await api(`/smart-schedule/best-times?${params}`);
|
|
763
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
764
|
+
});
|
|
765
|
+
server.tool("suggest_schedule", "Ask the smart scheduler to spread N posts across a date range at optimal times. Returns concrete timestamps for create_post.", {
|
|
766
|
+
numberOfPosts: z.number().describe("How many posts to schedule"),
|
|
767
|
+
platforms: z.array(z.string()).describe("Target platforms (e.g. ['instagram','linkedin'])"),
|
|
768
|
+
startDate: z.string().describe("Range start (ISO 8601)"),
|
|
769
|
+
endDate: z.string().describe("Range end (ISO 8601)"),
|
|
770
|
+
}, async ({ numberOfPosts, platforms, startDate, endDate }) => {
|
|
771
|
+
const data = await api("/smart-schedule/suggest", {
|
|
772
|
+
method: "POST",
|
|
773
|
+
body: JSON.stringify({ numberOfPosts, platforms, startDate, endDate }),
|
|
774
|
+
});
|
|
775
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
// ─── Start ───
|
|
779
|
+
async function startStdio() {
|
|
780
|
+
if (!ENV_API_KEY) {
|
|
781
|
+
console.error("PLANPOST_API_KEY environment variable is required");
|
|
782
|
+
process.exit(1);
|
|
783
|
+
}
|
|
784
|
+
const transport = new StdioServerTransport();
|
|
785
|
+
await buildServer().connect(transport);
|
|
786
|
+
}
|
|
787
|
+
function readBody(req) {
|
|
788
|
+
return new Promise((resolve, reject) => {
|
|
789
|
+
let raw = "";
|
|
790
|
+
req.on("data", (chunk) => {
|
|
791
|
+
raw += chunk;
|
|
792
|
+
if (raw.length > 4 * 1024 * 1024)
|
|
793
|
+
reject(new Error("Body too large"));
|
|
794
|
+
});
|
|
795
|
+
req.on("end", () => {
|
|
796
|
+
try {
|
|
797
|
+
resolve(raw ? JSON.parse(raw) : undefined);
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
reject(new Error("Invalid JSON body"));
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
req.on("error", reject);
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Remote/streamable-HTTP mode: `planpost-mcp --http [--port 3020]`.
|
|
808
|
+
*
|
|
809
|
+
* Stateless — every POST /mcp builds a fresh server + transport, and the
|
|
810
|
+
* caller's `Authorization: Bearer pk_...` key is scoped to that request via
|
|
811
|
+
* AsyncLocalStorage. That makes one hosted instance safely multi-tenant:
|
|
812
|
+
* whoever holds a PlanPost API key can point any HTTP-capable MCP client
|
|
813
|
+
* (e.g. `claude mcp add --transport http planpost https://host/mcp --header
|
|
814
|
+
* "Authorization: Bearer pk_..."`) at it without local installs.
|
|
815
|
+
*/
|
|
816
|
+
async function startHttp(port) {
|
|
817
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
818
|
+
const fail = (status, message) => {
|
|
819
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
820
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
|
|
821
|
+
};
|
|
822
|
+
try {
|
|
823
|
+
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
824
|
+
if (url.pathname === "/health") {
|
|
825
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
826
|
+
res.end(JSON.stringify({ status: "ok" }));
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (url.pathname !== "/mcp")
|
|
830
|
+
return fail(404, "Not found — MCP endpoint is /mcp");
|
|
831
|
+
if (req.method !== "POST") {
|
|
832
|
+
// Stateless mode: no SSE resumption stream, no sessions to delete.
|
|
833
|
+
return fail(405, "Method not allowed — stateless transport accepts POST only");
|
|
834
|
+
}
|
|
835
|
+
const auth = req.headers.authorization || "";
|
|
836
|
+
const key = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
837
|
+
if (!key.startsWith("pk_")) {
|
|
838
|
+
return fail(401, "Provide your PlanPost API key: Authorization: Bearer pk_...");
|
|
839
|
+
}
|
|
840
|
+
const body = await readBody(req);
|
|
841
|
+
const server = buildServer();
|
|
842
|
+
const transport = new StreamableHTTPServerTransport({
|
|
843
|
+
sessionIdGenerator: undefined, // stateless
|
|
844
|
+
});
|
|
845
|
+
res.on("close", () => {
|
|
846
|
+
transport.close();
|
|
847
|
+
server.close();
|
|
848
|
+
});
|
|
849
|
+
await server.connect(transport);
|
|
850
|
+
await requestApiKey.run(key, () => transport.handleRequest(req, res, body));
|
|
851
|
+
}
|
|
852
|
+
catch (err) {
|
|
853
|
+
console.error("[planpost-mcp] request error:", err);
|
|
854
|
+
if (!res.headersSent)
|
|
855
|
+
fail(500, "Internal error");
|
|
856
|
+
}
|
|
857
|
+
});
|
|
858
|
+
httpServer.listen(port, () => {
|
|
859
|
+
console.log(`planpost-mcp listening on http://0.0.0.0:${port}/mcp (streamable HTTP, stateless)`);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
async function main() {
|
|
863
|
+
const args = process.argv.slice(2);
|
|
864
|
+
if (args.includes("--http")) {
|
|
865
|
+
const portArg = args[args.indexOf("--port") + 1];
|
|
866
|
+
const port = Number(portArg) || Number(process.env.PORT) || 3020;
|
|
867
|
+
await startHttp(port);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
await startStdio();
|
|
871
|
+
}
|
|
872
|
+
main().catch((err) => {
|
|
873
|
+
console.error("MCP server error:", err);
|
|
874
|
+
process.exit(1);
|
|
875
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "planpost-mcp",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "PlanPost MCP server — let Claude and other AI agents schedule posts, manage social media, create videos and carousels, and read analytics via the PlanPost public API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"planpost-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"skills"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://planpo.st",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/lukBB/social-poster-v2.git",
|
|
24
|
+
"directory": "packages/mcp-server"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"mcp",
|
|
28
|
+
"model-context-protocol",
|
|
29
|
+
"claude",
|
|
30
|
+
"ai-agent",
|
|
31
|
+
"social-media",
|
|
32
|
+
"scheduling",
|
|
33
|
+
"planpost"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc",
|
|
37
|
+
"dev": "tsc --watch",
|
|
38
|
+
"start": "node dist/index.js",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
43
|
+
"zod": "^4.3.6"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"typescript": "^5.7.0",
|
|
47
|
+
"@types/node": "^22.0.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: send-to-plan-post
|
|
3
|
+
description: Compose and route social posts through the user's PlanPost app. Resolves named account groups ("Polish Pages", "English Pages", "SaaS", "Personal", etc.) into PlanPost connection IDs and creates draft, scheduled, or immediately-published posts. MANDATORY TRIGGERS - "send to plan post", "/send-to-plan-post", "post to PlanPost", "publish via PlanPost", "save as draft on PlanPost", "post to my <group> pages", "post this to my <group> group", "draft on PlanPost", "schedule on PlanPost", "post to Polish Pages", "post to English Pages". Also trigger when the user pastes social copy and references any known group name. Requires the planpost-mcp server registered with PLANPOST_API_KEY.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# send-to-plan-post
|
|
7
|
+
|
|
8
|
+
## What this skill does
|
|
9
|
+
|
|
10
|
+
Routes user-provided content to the right set of social accounts in PlanPost, using account groups as the primary targeting concept (with a fallback to individual handle targeting). Supports draft, scheduled, and immediate-publish modes. Handles image/video attachments via PlanPost's presigned S3 upload flow.
|
|
11
|
+
|
|
12
|
+
## Required MCP tools (`planpost-mcp`)
|
|
13
|
+
|
|
14
|
+
The user must have the `planpost-mcp` server installed and authenticated with `PLANPOST_API_KEY`. This skill calls:
|
|
15
|
+
|
|
16
|
+
- `list_account_groups` — resolve a group name into its connections
|
|
17
|
+
- `get_account_group` — fetch one group (by id, name, or default flag)
|
|
18
|
+
- `list_connections` — fallback when targeting individual accounts
|
|
19
|
+
- `upload_media` — presigned-URL upload for attached files
|
|
20
|
+
- `create_post` — draft or scheduled creation
|
|
21
|
+
- `publish_post` — immediate publish of a created post
|
|
22
|
+
- (read-only) `list_posts`, `get_post` — for "show me what I just sent"
|
|
23
|
+
|
|
24
|
+
The API key needs these permissions: `groups:read`, `connections:read`, `posts:read`, `posts:write`, `media:write`.
|
|
25
|
+
|
|
26
|
+
If the MCP server is missing or the API key is unset, stop and tell the user how to install/configure it (see `packages/mcp-server/README.md`). Do not fall back to web scraping or computer-use clicking through the dashboard.
|
|
27
|
+
|
|
28
|
+
## Flow
|
|
29
|
+
|
|
30
|
+
### Step 1 — Parse the request
|
|
31
|
+
|
|
32
|
+
From the user's message extract:
|
|
33
|
+
|
|
34
|
+
- **Content** — the post body. Preserve the user's language exactly (Polish stays Polish, English stays English). Do not "improve" copy unless asked.
|
|
35
|
+
- **Target** — one of: a group name ("Polish Pages"), a list of handles ("@triathlon_info_pl on IG"), or "default".
|
|
36
|
+
- **Mode** — `draft` | `schedule` | `publish-now`. **Default to `draft` when ambiguous.** Only schedule or publish if the user explicitly asks for it.
|
|
37
|
+
- **Schedule time** — if mode is `schedule`, an ISO datetime or natural phrase ("tomorrow 9am"). Resolve to ISO 8601 in the user's timezone before sending.
|
|
38
|
+
- **Media** — any files attached in the chat or URLs the user pasted.
|
|
39
|
+
- **Platform overrides** — per-platform variants (`platformContent`) or settings (`platformSettings`, e.g. `{ instagram: { isReel: true } }`) only if the user gave them explicitly.
|
|
40
|
+
|
|
41
|
+
### Step 2 — Resolve targets
|
|
42
|
+
|
|
43
|
+
- **Group by name** → call `list_account_groups`, do a case-insensitive exact-name match first; if no hit, try substring; if multiple match, ask the user to pick from the matches.
|
|
44
|
+
- **Default group** → call `get_account_group` with `{ default: true }`.
|
|
45
|
+
- **Specific handles** → call `list_connections`, filter by `provider` + `providerUsername` (lowercase compare, strip leading `@`).
|
|
46
|
+
- **No target resolved** → stop, list every group the user has, ask which one.
|
|
47
|
+
|
|
48
|
+
Capture the resolved `connectionIds[]` and the human-readable list (`provider @handle`) for the confirmation step.
|
|
49
|
+
|
|
50
|
+
### Step 3 — Handle media
|
|
51
|
+
|
|
52
|
+
For each attached file:
|
|
53
|
+
|
|
54
|
+
1. Call `upload_media` with `{ filename, contentType, size }` to get a presigned PUT URL.
|
|
55
|
+
2. PUT the file bytes.
|
|
56
|
+
3. Capture the returned `{ id, type, url, key, filename, mimeType, size }` and append to a `media[]` array.
|
|
57
|
+
|
|
58
|
+
For pasted URLs: fetch the bytes (respect link-safety rules — verify unfamiliar URLs with the user before fetching), then upload via the same flow. If a URL fails to fetch, ask the user to attach the file directly instead.
|
|
59
|
+
|
|
60
|
+
### Step 4 — Compose and confirm
|
|
61
|
+
|
|
62
|
+
Before any write that publishes publicly, show a confirmation block:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
About to <draft | schedule | publish-now>:
|
|
66
|
+
|
|
67
|
+
Group: Polish Pages (4 accounts)
|
|
68
|
+
- facebook @triathloninfopl
|
|
69
|
+
- instagram @triathlon_info_pl
|
|
70
|
+
- threads @triathlon_info_pl
|
|
71
|
+
- twitter @triathlon_info_pl
|
|
72
|
+
When: <Now | 2026-05-19 09:00 Europe/Warsaw | Draft only>
|
|
73
|
+
Content: <body, truncated to ~200 chars with [...] if longer>
|
|
74
|
+
Media: <N items, names>
|
|
75
|
+
Mode: <publish-now | schedule | draft>
|
|
76
|
+
|
|
77
|
+
Reply "go" to execute, or describe edits.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Rules:
|
|
81
|
+
|
|
82
|
+
- **Draft**: a confirmation summary is shown but the skill may proceed without an explicit "go" if the user's instruction was already unambiguous. (Drafts are reversible.)
|
|
83
|
+
- **Schedule**: require explicit "go".
|
|
84
|
+
- **Publish-now**: require explicit "go". If 5+ accounts are targeted, require a second `"yes publish to all <N>"` confirmation.
|
|
85
|
+
|
|
86
|
+
### Step 5 — Execute
|
|
87
|
+
|
|
88
|
+
- Call `create_post` with `{ content, connectionIds, media, scheduledFor?, platformContent?, platformSettings?, timezone? }`.
|
|
89
|
+
- If mode is `publish-now`: take the returned post id and call `publish_post`.
|
|
90
|
+
- If mode is `schedule`: setting `scheduledFor` on `create_post` is enough; do not call `publish_post`.
|
|
91
|
+
- If mode is `draft`: do not set `scheduledFor` and do not call `publish_post`.
|
|
92
|
+
|
|
93
|
+
### Step 6 — Report back
|
|
94
|
+
|
|
95
|
+
Format the result tightly:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
Created post <id> as <status>.
|
|
99
|
+
|
|
100
|
+
Targets:
|
|
101
|
+
- facebook @triathloninfopl queued
|
|
102
|
+
- instagram @triathlon_info_pl queued
|
|
103
|
+
- threads @triathlon_info_pl failed (token expired — re-auth at planpo.st)
|
|
104
|
+
|
|
105
|
+
Dashboard: https://planpo.st/posts/<id>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Surface per-platform results (`platformResults[]`) from the API response. **Do not swallow partial failures.**
|
|
109
|
+
|
|
110
|
+
## Examples
|
|
111
|
+
|
|
112
|
+
### Draft to a group
|
|
113
|
+
|
|
114
|
+
> "draft a post on PlanPost to my English Pages — 'New SoloOperatorStack post on building Claude skills over PlanPost. Link in bio.'"
|
|
115
|
+
|
|
116
|
+
→ Resolve "English Pages" via `list_account_groups`. Show confirmation. Call `create_post` with no `scheduledFor`. Report id.
|
|
117
|
+
|
|
118
|
+
### Publish-now to specific handles
|
|
119
|
+
|
|
120
|
+
> "post to @triathlon_info_pl on Instagram and Facebook only: 'Trening interwałowy 6x800 — kto dziś biegnie?'"
|
|
121
|
+
|
|
122
|
+
→ Skip group resolution. Filter `list_connections` to those two. Confirm (4+ rule does not apply). `create_post` → `publish_post`.
|
|
123
|
+
|
|
124
|
+
### Schedule with image
|
|
125
|
+
|
|
126
|
+
> User drops `cover.jpg`: "schedule this for tomorrow 9am to Polish Pages: 'Lista startów na weekend ...'"
|
|
127
|
+
|
|
128
|
+
→ `upload_media` for the image. Resolve Polish Pages via `get_account_group`. Resolve "tomorrow 9am" to ISO in `Europe/Warsaw`. Confirm. `create_post` with `scheduledFor`.
|
|
129
|
+
|
|
130
|
+
### Discovery
|
|
131
|
+
|
|
132
|
+
> "what groups do I have on PlanPost?"
|
|
133
|
+
|
|
134
|
+
→ `list_account_groups`. Format as table: name, account count, default flag, color.
|
|
135
|
+
|
|
136
|
+
## Safety rules
|
|
137
|
+
|
|
138
|
+
- Never publish without explicit "go" / "publish" / "yes" from the user.
|
|
139
|
+
- Never invent a group name — if the user names a group that doesn't exist, list what does exist and ask.
|
|
140
|
+
- Never silently drop a failed-per-account result — always surface it in the final report.
|
|
141
|
+
- Preserve the user's language and tone in the post body. Do not paraphrase.
|
|
142
|
+
- For pasted URLs, follow link-safety rules: confirm unfamiliar URLs with the user before fetching their contents.
|
|
143
|
+
|
|
144
|
+
## Failure modes and how to handle them
|
|
145
|
+
|
|
146
|
+
- **API key missing** → "Set `PLANPOST_API_KEY` in your MCP config. See `packages/mcp-server/README.md`."
|
|
147
|
+
- **`groups:read` permission missing** → "Your API key doesn't have the `groups:read` permission. Regenerate it at planpo.st/settings/api-keys with that scope checked, or pass connection IDs directly via `list_connections`."
|
|
148
|
+
- **Group not found** → "I don't see a group called 'X'. You have: <list>. Want me to use one of these?"
|
|
149
|
+
- **Ambiguous group name (HTTP 300)** → list the candidates returned by the API, ask the user to pick by id.
|
|
150
|
+
- **Connection inactive (token expired)** → call this out before composing; suggest re-auth in the dashboard.
|
|
151
|
+
- **Upload fails** → retry once, then ask the user to attach again or paste a different URL.
|
|
152
|
+
- **Backend 5xx on `create_post`** → do not retry automatically. Confirm with the user first to avoid duplicate posts.
|