planpost-mcp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -10
- package/dist/index.js +69 -14
- package/package.json +28 -7
package/README.md
CHANGED
|
@@ -12,13 +12,36 @@ In the PlanPost dashboard go to **Settings → API Keys → Create Key** and sel
|
|
|
12
12
|
|
|
13
13
|
### 2. Add the server to your agent
|
|
14
14
|
|
|
15
|
-
**
|
|
15
|
+
Two ways to connect. **Hosted** is the default: point your client at
|
|
16
|
+
`https://mcp.planpo.st/mcp`, nothing to install, your key travels as a header.
|
|
17
|
+
**Local** runs the same server on your machine with `npx`, so the key never
|
|
18
|
+
leaves it. Both expose the same 47 tools.
|
|
19
|
+
|
|
20
|
+
Replace `pk_live_xxx` with your key throughout.
|
|
21
|
+
|
|
22
|
+
**Claude Code**
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
claude mcp add --transport http planpost https://mcp.planpo.st/mcp \
|
|
26
|
+
--header "Authorization: Bearer pk_live_xxx"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Local fallback:
|
|
16
30
|
|
|
17
31
|
```bash
|
|
18
32
|
claude mcp add planpost -e PLANPOST_API_KEY=pk_live_xxx -- npx -y planpost-mcp
|
|
19
33
|
```
|
|
20
34
|
|
|
21
|
-
|
|
35
|
+
Run `claude mcp list` afterwards to confirm it is connected.
|
|
36
|
+
|
|
37
|
+
**Claude Desktop**
|
|
38
|
+
|
|
39
|
+
Hosted: Settings → Connectors → Add custom connector. Name `PlanPost`, URL
|
|
40
|
+
`https://mcp.planpo.st/mcp`, then add the header `Authorization: Bearer pk_live_xxx`.
|
|
41
|
+
|
|
42
|
+
Local fallback, in `claude_desktop_config.json` (macOS:
|
|
43
|
+
`~/Library/Application Support/Claude/`, Windows: `%APPDATA%\Claude\`), then
|
|
44
|
+
restart the app:
|
|
22
45
|
|
|
23
46
|
```json
|
|
24
47
|
{
|
|
@@ -32,24 +55,87 @@ claude mcp add planpost -e PLANPOST_API_KEY=pk_live_xxx -- npx -y planpost-mcp
|
|
|
32
55
|
}
|
|
33
56
|
```
|
|
34
57
|
|
|
35
|
-
**
|
|
58
|
+
**Cursor** (`~/.cursor/mcp.json`, or `.cursor/mcp.json` per project)
|
|
36
59
|
|
|
37
|
-
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"planpost": {
|
|
64
|
+
"url": "https://mcp.planpo.st/mcp",
|
|
65
|
+
"headers": { "Authorization": "Bearer pk_live_xxx" }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Local fallback: replace the entry with
|
|
72
|
+
`{ "command": "npx", "args": ["-y", "planpost-mcp"], "env": { "PLANPOST_API_KEY": "pk_live_xxx" } }`.
|
|
73
|
+
A project-level `.cursor/mcp.json` is committed with your repo, so prefer the
|
|
74
|
+
hosted form and keep the key out of the file.
|
|
75
|
+
|
|
76
|
+
**OpenAI Codex CLI** (`~/.codex/config.toml`)
|
|
77
|
+
|
|
78
|
+
```toml
|
|
79
|
+
[mcp_servers.planpost]
|
|
80
|
+
url = "https://mcp.planpo.st/mcp"
|
|
81
|
+
bearer_token_env_var = "PLANPOST_API_KEY"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Export `PLANPOST_API_KEY` in your shell; Codex reads the token from the
|
|
85
|
+
environment, not the file. `codex mcp add` does the same thing interactively.
|
|
86
|
+
|
|
87
|
+
Local fallback:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[mcp_servers.planpost]
|
|
91
|
+
command = "npx"
|
|
92
|
+
args = ["-y", "planpost-mcp"]
|
|
38
93
|
|
|
39
|
-
|
|
94
|
+
[mcp_servers.planpost.env]
|
|
95
|
+
PLANPOST_API_KEY = "pk_live_xxx"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**Gemini CLI** (`~/.gemini/settings.json`, or `.gemini/settings.json` per project)
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"mcpServers": {
|
|
103
|
+
"planpost": {
|
|
104
|
+
"httpUrl": "https://mcp.planpo.st/mcp",
|
|
105
|
+
"headers": { "Authorization": "Bearer pk_live_xxx" },
|
|
106
|
+
"timeout": 10000
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Gemini CLI uses `httpUrl` for streamable HTTP, not `url`; the wrong key is a
|
|
113
|
+
silent no-op. Local fallback: the same `command`/`args`/`env` entry as Cursor.
|
|
114
|
+
|
|
115
|
+
**Any other MCP client:** connect to `https://mcp.planpo.st/mcp` (streamable
|
|
116
|
+
HTTP, `Authorization: Bearer pk_live_xxx` on every request), or run
|
|
117
|
+
`npx -y planpost-mcp` over stdio with `PLANPOST_API_KEY` set. Per-client
|
|
118
|
+
walkthroughs with screenshots: [planpo.st/docs/agents](https://planpo.st/docs/agents).
|
|
119
|
+
|
|
120
|
+
### Self-hosting the HTTP endpoint
|
|
121
|
+
|
|
122
|
+
`mcp.planpo.st` is this same package running with `--http`. To run your own:
|
|
40
123
|
|
|
41
124
|
```bash
|
|
42
125
|
npx -y planpost-mcp --http --port 3020 # exposes POST /mcp (+ /health)
|
|
43
126
|
```
|
|
44
127
|
|
|
45
|
-
|
|
128
|
+
Behind a reverse proxy, bind to loopback so the port isn't reachable directly:
|
|
46
129
|
|
|
47
130
|
```bash
|
|
48
|
-
|
|
49
|
-
--header "Authorization: Bearer pk_live_xxx"
|
|
131
|
+
HOST=127.0.0.1 npx -y planpost-mcp --http --port 3020
|
|
50
132
|
```
|
|
51
133
|
|
|
52
|
-
The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
|
|
134
|
+
The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
|
|
135
|
+
header is required on every request and scoped per request, so one hosted
|
|
136
|
+
instance serves many users safely. Put it behind HTTPS in production.
|
|
137
|
+
`--host` / `HOST` default to `0.0.0.0`, so containers and LAN use work without
|
|
138
|
+
extra flags.
|
|
53
139
|
|
|
54
140
|
### 3. Talk to it
|
|
55
141
|
|
|
@@ -70,6 +156,8 @@ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
|
|
|
70
156
|
|
|
71
157
|
## Tools
|
|
72
158
|
|
|
159
|
+
47 tools, grouped below. The permission column is the API-key scope a tool needs; a key without the scope gets a clear error rather than a silent failure.
|
|
160
|
+
|
|
73
161
|
### Posts & publishing
|
|
74
162
|
|
|
75
163
|
| Tool | Required permission | Description |
|
|
@@ -80,6 +168,11 @@ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
|
|
|
80
168
|
| `update_post` | `posts:write` | Update a draft/scheduled post |
|
|
81
169
|
| `delete_post` | `posts:write` | Delete a post |
|
|
82
170
|
| `publish_post` | `posts:write` | Publish immediately |
|
|
171
|
+
| `preview_post` | `posts:read` | Show exactly what each platform will receive (text after overrides, attachments, warnings) |
|
|
172
|
+
| `bulk_create_posts` | `posts:write` | Create a whole campaign of posts from one manifest |
|
|
173
|
+
| `list_campaigns` | `posts:read` | Campaign tags in use, with post counts by status and platform |
|
|
174
|
+
| `delete_posts` | `posts:write` | Bulk-delete posts matching a filter (a filter is required) |
|
|
175
|
+
| `list_media` | `media:read` | Media already uploaded, so an asset can be reused instead of re-uploaded |
|
|
83
176
|
| `upload_media` | `media:write` | Get a presigned URL to upload an image/video |
|
|
84
177
|
| `repurpose_content` | `repurpose:write` | Save repurposed content as draft posts |
|
|
85
178
|
|
|
@@ -90,8 +183,31 @@ The transport is stateless (streamable HTTP); the `Authorization: Bearer pk_...`
|
|
|
90
183
|
| `list_connections` | `connections:read` | Connected social accounts |
|
|
91
184
|
| `list_account_groups` | `groups:read` | Named account groups (e.g. "Polish Pages") |
|
|
92
185
|
| `get_account_group` | `groups:read` | Resolve a group by id/name/default → connection IDs |
|
|
186
|
+
| `create_account_group` | `groups:write` | Create a named group bundling connections |
|
|
187
|
+
| `update_account_group` | `groups:write` | Rename, recolour, set default, or replace a group's connections |
|
|
188
|
+
| `delete_account_group` | `groups:write` | Delete a group (the accounts stay connected) |
|
|
189
|
+
| `add_connections_to_group` | `groups:write` | Add connections to a group |
|
|
190
|
+
| `remove_connections_from_group` | `groups:write` | Remove connections from a group |
|
|
191
|
+
| `get_connection_link` | `connections:read` | Link the user opens in a browser to connect a new social account (OAuth needs a human) |
|
|
192
|
+
|
|
193
|
+
Connecting a new social account requires OAuth in a browser: `get_connection_link` hands the user the link, and the agent can target the account once it is connected.
|
|
194
|
+
|
|
195
|
+
### Approvals
|
|
196
|
+
|
|
197
|
+
| Tool | Required permission | Description |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `submit_for_approval` | `approvals:write` | Send a post to a human reviewer (required when the API key is approval-gated) |
|
|
200
|
+
| `list_pending_approvals` | `approvals:read` | Posts waiting for review, with outcomes |
|
|
201
|
+
| `get_approval_status` | `approvals:read` | Pending / approved / rejected / changes requested for one submission |
|
|
93
202
|
|
|
94
|
-
|
|
203
|
+
### Scheduling
|
|
204
|
+
|
|
205
|
+
| Tool | Required permission | Description |
|
|
206
|
+
|---|---|---|
|
|
207
|
+
| `get_best_times` | `schedule:read` | Best posting times per platform from the account's engagement history |
|
|
208
|
+
| `suggest_schedule` | `schedule:read` | Spread N posts across a date range at optimal times; returns concrete timestamps |
|
|
209
|
+
| `get_posting_schedule` | `schedule:read` | Weekly queue slots, global plus per-group overrides |
|
|
210
|
+
| `set_posting_schedule` | `schedule:write` | Replace the weekly queue slots for the global schedule or one group (Pro) |
|
|
95
211
|
|
|
96
212
|
### Video & carousels
|
|
97
213
|
|
|
@@ -105,6 +221,7 @@ Connecting a new social account requires OAuth in the dashboard — agents can t
|
|
|
105
221
|
| `render_carousel` | `carousels:write` | Render slides into branded PNGs in the media library |
|
|
106
222
|
| `get_carousel_render` | `carousels:read` | Poll a render; completed renders return post-ready media assets |
|
|
107
223
|
| `list_brand_kits` | `brand-kits:read` | Saved brand kits (palette, fonts, logo, handle) for `render_carousel` |
|
|
224
|
+
| `generate_image` | `images:write` | Generate an image with AI for a post (1 AI credit); pass the URL to `create_post` |
|
|
108
225
|
|
|
109
226
|
### Content sources
|
|
110
227
|
|
|
@@ -125,6 +242,13 @@ Connecting a new social account requires OAuth in the dashboard — agents can t
|
|
|
125
242
|
|
|
126
243
|
Revenue data is read-only over the API; connecting Stripe/RevenueCat/Shopify happens in the dashboard (Settings → Revenue).
|
|
127
244
|
|
|
245
|
+
### Brand voice (Pro)
|
|
246
|
+
|
|
247
|
+
| Tool | Required permission | Description |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| `get_brand_voices` | `brand-voice:read` | Tone-of-voice profiles analysed from the user's content |
|
|
250
|
+
| `get_brand_voice_prompt` | `brand-voice:read` | Ready-to-use system prompt for a voice; apply it when drafting posts |
|
|
251
|
+
|
|
128
252
|
## A typical agent workflow
|
|
129
253
|
|
|
130
254
|
1. `get_account_group` (`default: true`) → connection IDs
|
|
@@ -144,11 +268,54 @@ Or, for a branded carousel post end-to-end:
|
|
|
144
268
|
```bash
|
|
145
269
|
npm install
|
|
146
270
|
npm run build # tsc → dist/
|
|
271
|
+
npm test # validates server.json against the registry schema, boots the stdio server
|
|
147
272
|
PLANPOST_API_KEY=pk_test_xxx node dist/index.js
|
|
148
273
|
```
|
|
149
274
|
|
|
150
275
|
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
276
|
|
|
277
|
+
## Maintainers
|
|
278
|
+
|
|
279
|
+
Releasing a version means publishing twice: the code to npm, then the metadata
|
|
280
|
+
to the [official MCP Registry](https://registry.modelcontextprotocol.io), which
|
|
281
|
+
only stores `server.json` and checks that the npm package it points at exists
|
|
282
|
+
and carries the matching `mcpName`. Glama and other directories import from the
|
|
283
|
+
registry, so the second step is what makes a release discoverable.
|
|
284
|
+
|
|
285
|
+
1. Bump `version` in `package.json` **and** in `server.json` (top level and
|
|
286
|
+
`packages[0].version`). `npm test` fails if they disagree.
|
|
287
|
+
2. Publish to npm first; the registry rejects a version npm does not have yet:
|
|
288
|
+
|
|
289
|
+
```bash
|
|
290
|
+
npm publish --access public
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
3. Install the publisher CLI once (`brew install mcp-publisher`, or the tarball
|
|
294
|
+
from the registry's GitHub releases), then log in. The `io.github.lukBB/*`
|
|
295
|
+
namespace is proven by GitHub device-flow login as that account:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
mcp-publisher login github
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
4. Validate and publish from this directory:
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
mcp-publisher validate
|
|
305
|
+
mcp-publisher publish
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
5. Confirm the listing:
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.lukBB/planpost"
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
An expired token shows as "Invalid or expired Registry JWT token": run the login
|
|
315
|
+
again. `mcp-publisher status` deprecates or deletes a published version.
|
|
316
|
+
Directory listings beyond the registry (mcp.so, Cursor Directory, outreach) are
|
|
317
|
+
tracked in [DISTRIBUTION.md](./DISTRIBUTION.md).
|
|
318
|
+
|
|
152
319
|
## License
|
|
153
320
|
|
|
154
321
|
MIT
|
package/dist/index.js
CHANGED
|
@@ -74,7 +74,10 @@ function buildServer() {
|
|
|
74
74
|
// ─── Tools ───
|
|
75
75
|
function registerTools(server) {
|
|
76
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
|
-
|
|
77
|
+
// `needs_approval` is a first-class status, not an internal one: a post
|
|
78
|
+
// parked for human sign-off is exactly what an agent needs to be able to
|
|
79
|
+
// find again after submitting it.
|
|
80
|
+
status: z.enum(["draft", "needs_approval", "scheduled", "processing", "published", "failed", "cancelled"]).optional().describe("Filter by post status ('needs_approval' = waiting on human sign-off)"),
|
|
78
81
|
platform: z.string().optional().describe("Filter by platform (instagram, twitter, linkedin, etc.)"),
|
|
79
82
|
campaign: z.string().optional().describe("Filter by campaign tag, e.g. 'garmin-cirqa-2026' (matches utm.campaign; slugified automatically)"),
|
|
80
83
|
label: z.string().optional().describe("Filter by internal label (case-insensitive substring), e.g. 'Garmin CIRQA'"),
|
|
@@ -112,7 +115,7 @@ function registerTools(server) {
|
|
|
112
115
|
const data = await api(`/posts/${id}`);
|
|
113
116
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
114
117
|
});
|
|
115
|
-
server.tool("create_post", "Create a new social media post. Saves as draft or schedules it for a specific time.", {
|
|
118
|
+
server.tool("create_post", "Create a new social media post. Saves as draft or schedules it for a specific time. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
|
|
116
119
|
content: z.string().describe("Post text content"),
|
|
117
120
|
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
121
|
platformContent: z.record(z.string(), z.string()).optional().describe("Per-platform content overrides, e.g. { twitter: 'shorter version', linkedin: 'longer version' }"),
|
|
@@ -193,7 +196,9 @@ function registerTools(server) {
|
|
|
193
196
|
const media = pl.mediaCount === 0
|
|
194
197
|
? `no media${pl.mediaSource === "override" ? " (text-only override)" : ""}`
|
|
195
198
|
: `${pl.mediaCount} ${pl.mediaCount === 1 ? "item" : "items"} (${pl.media.map((m) => m.filename ?? m.type).join(", ")})`;
|
|
196
|
-
const warn = pl.warnings?.length
|
|
199
|
+
const warn = pl.warnings?.length
|
|
200
|
+
? `\n warnings:\n${pl.warnings.map((w) => ` - [${w.code}] ${w.message}`).join("\n")}`
|
|
201
|
+
: "";
|
|
197
202
|
return [
|
|
198
203
|
`── ${pl.platform.toUpperCase()} ─────────────`,
|
|
199
204
|
` text (${pl.contentSource}): ${limit}${pl.linksClickable ? "" : " [links NOT clickable]"}`,
|
|
@@ -212,7 +217,7 @@ function registerTools(server) {
|
|
|
212
217
|
}],
|
|
213
218
|
};
|
|
214
219
|
});
|
|
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.", {
|
|
220
|
+
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. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
|
|
216
221
|
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
222
|
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
223
|
}, async ({ posts, defaults }) => {
|
|
@@ -280,7 +285,7 @@ function registerTools(server) {
|
|
|
280
285
|
const data = await api(`/media?${params}`);
|
|
281
286
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
282
287
|
});
|
|
283
|
-
server.tool("update_post", "Update a draft or scheduled post.", {
|
|
288
|
+
server.tool("update_post", "Update a draft or scheduled post. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
|
|
284
289
|
id: z.string().describe("Post ID to update"),
|
|
285
290
|
content: z.string().optional().describe("New post text content"),
|
|
286
291
|
connectionIds: z.array(z.string()).optional().describe("New connection IDs"),
|
|
@@ -356,7 +361,7 @@ function registerTools(server) {
|
|
|
356
361
|
await api(`/posts/${id}`, { method: "DELETE" });
|
|
357
362
|
return { content: [{ type: "text", text: `Post ${id} deleted.` }] };
|
|
358
363
|
});
|
|
359
|
-
server.tool("publish_post", "Publish a draft or scheduled post immediately to all connected platforms.", {
|
|
364
|
+
server.tool("publish_post", "Publish a draft or scheduled post immediately to all connected platforms. X (Twitter): nothing sent to X may contain a link (URL, bare domain or t.co) — not the post, any thread segment, or a first comment (posted to X as a reply). The X API bills each link post at $0.20 vs $0.015, so the API rejects them; put a link-free version in platformContent.twitter.", {
|
|
360
365
|
id: z.string().describe("Post ID to publish now"),
|
|
361
366
|
}, async ({ id }) => {
|
|
362
367
|
const data = await api(`/posts/${id}/publish`, { method: "POST" });
|
|
@@ -774,6 +779,31 @@ function registerTools(server) {
|
|
|
774
779
|
});
|
|
775
780
|
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
776
781
|
});
|
|
782
|
+
server.tool("get_posting_schedule", "Read the weekly posting queue schedule: the global schedule plus any per-group overrides. Slots are {day: 1-7 (Mon=1..Sun=7), time: 'HH:MM' 24h} in the user's timezone. Groups without an override inherit the global schedule. Needs the schedule:read permission.", {}, async () => {
|
|
783
|
+
const data = await api("/posting-queue/queues");
|
|
784
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
785
|
+
});
|
|
786
|
+
server.tool("set_posting_schedule", "Replace the weekly posting queue slots for the global schedule or one account group (Pro feature; needs the schedule:write permission). Pass the FULL desired slot list — it replaces the existing one. An empty array on a group removes its override so it inherits the global schedule again.", {
|
|
787
|
+
groupId: z
|
|
788
|
+
.string()
|
|
789
|
+
.optional()
|
|
790
|
+
.describe("Account group id (from list_account_groups); omit for the global schedule"),
|
|
791
|
+
slots: z
|
|
792
|
+
.array(z.object({
|
|
793
|
+
day: z.number().int().min(1).max(7).describe("ISO weekday: 1=Monday .. 7=Sunday"),
|
|
794
|
+
time: z
|
|
795
|
+
.string()
|
|
796
|
+
.regex(/^([01]\d|2[0-3]):[0-5]\d$/)
|
|
797
|
+
.describe("24h time 'HH:MM' in the user's timezone, e.g. '09:30'"),
|
|
798
|
+
}))
|
|
799
|
+
.describe("The complete weekly slot list to store"),
|
|
800
|
+
}, async ({ groupId, slots }) => {
|
|
801
|
+
const data = await api(`/posting-queue/queues/${encodeURIComponent(groupId ?? "global")}`, {
|
|
802
|
+
method: "PUT",
|
|
803
|
+
body: JSON.stringify({ slots }),
|
|
804
|
+
});
|
|
805
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
806
|
+
});
|
|
777
807
|
}
|
|
778
808
|
// ─── Start ───
|
|
779
809
|
async function startStdio() {
|
|
@@ -813,24 +843,44 @@ function readBody(req) {
|
|
|
813
843
|
* (e.g. `claude mcp add --transport http planpost https://host/mcp --header
|
|
814
844
|
* "Authorization: Bearer pk_..."`) at it without local installs.
|
|
815
845
|
*/
|
|
816
|
-
async function startHttp(port) {
|
|
846
|
+
async function startHttp(port, host) {
|
|
817
847
|
const httpServer = createHttpServer(async (req, res) => {
|
|
848
|
+
// An API host must never rank: Google indexed the bare /mcp 405 JSON as a
|
|
849
|
+
// page under the brand. The header, not robots.txt, is the fix on purpose:
|
|
850
|
+
// a crawler has to be able to fetch the URL to see the noindex, so
|
|
851
|
+
// robots.txt below allows crawling and this header does the de-indexing.
|
|
852
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
|
818
853
|
const fail = (status, message) => {
|
|
819
|
-
|
|
854
|
+
// charset is load-bearing: without it, any non-ASCII byte in a message
|
|
855
|
+
// renders as mojibake in browsers that assume latin-1 for JSON.
|
|
856
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
820
857
|
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
|
|
821
858
|
};
|
|
822
859
|
try {
|
|
823
860
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
824
861
|
if (url.pathname === "/health") {
|
|
825
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
862
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
826
863
|
res.end(JSON.stringify({ status: "ok" }));
|
|
827
864
|
return;
|
|
828
865
|
}
|
|
866
|
+
if (url.pathname === "/robots.txt") {
|
|
867
|
+
// Allow crawling so the X-Robots-Tag noindex is actually seen.
|
|
868
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
869
|
+
res.end("User-agent: *\nDisallow:\n");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (url.pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
|
|
873
|
+
// A human (or crawler) landing on the bare host gets the docs page
|
|
874
|
+
// instead of a JSON error.
|
|
875
|
+
res.writeHead(302, { Location: "https://planpo.st/mcp" });
|
|
876
|
+
res.end();
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
829
879
|
if (url.pathname !== "/mcp")
|
|
830
|
-
return fail(404, "Not found
|
|
880
|
+
return fail(404, "Not found. The MCP endpoint is /mcp");
|
|
831
881
|
if (req.method !== "POST") {
|
|
832
882
|
// Stateless mode: no SSE resumption stream, no sessions to delete.
|
|
833
|
-
return fail(405, "Method not allowed
|
|
883
|
+
return fail(405, "Method not allowed. This stateless transport accepts POST only; setup guide at https://planpo.st/mcp");
|
|
834
884
|
}
|
|
835
885
|
const auth = req.headers.authorization || "";
|
|
836
886
|
const key = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
|
@@ -855,8 +905,8 @@ async function startHttp(port) {
|
|
|
855
905
|
fail(500, "Internal error");
|
|
856
906
|
}
|
|
857
907
|
});
|
|
858
|
-
httpServer.listen(port, () => {
|
|
859
|
-
console.log(`planpost-mcp listening on http
|
|
908
|
+
httpServer.listen(port, host, () => {
|
|
909
|
+
console.log(`planpost-mcp listening on http://${host}:${port}/mcp (streamable HTTP, stateless)`);
|
|
860
910
|
});
|
|
861
911
|
}
|
|
862
912
|
async function main() {
|
|
@@ -864,7 +914,12 @@ async function main() {
|
|
|
864
914
|
if (args.includes("--http")) {
|
|
865
915
|
const portArg = args[args.indexOf("--port") + 1];
|
|
866
916
|
const port = Number(portArg) || Number(process.env.PORT) || 3020;
|
|
867
|
-
|
|
917
|
+
// Defaults to all interfaces so `--http` works in a container or on a LAN
|
|
918
|
+
// without extra flags. Behind a reverse proxy set HOST=127.0.0.1 so the
|
|
919
|
+
// port isn't reachable except through the proxy.
|
|
920
|
+
const hostArg = args.includes("--host") ? args[args.indexOf("--host") + 1] : undefined;
|
|
921
|
+
const host = hostArg || process.env.HOST || "0.0.0.0";
|
|
922
|
+
await startHttp(port, host);
|
|
868
923
|
return;
|
|
869
924
|
}
|
|
870
925
|
await startStdio();
|
package/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "planpost-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"//mcpName": "Ownership marker for the official MCP Registry: must equal `name` in server.json, or `mcp-publisher publish` fails validation. Namespace is the GitHub account that owns this repo, verified with `mcp-publisher login github`.",
|
|
5
|
+
"mcpName": "io.github.lukBB/planpost",
|
|
6
|
+
"description": "PlanPost MCP server: schedule and publish social posts to Instagram, TikTok, LinkedIn, X, Facebook, Threads and YouTube from Claude, Cursor, Codex or any MCP client; render video and carousels, read analytics and revenue.",
|
|
5
7
|
"license": "MIT",
|
|
6
8
|
"type": "module",
|
|
7
9
|
"bin": {
|
|
@@ -17,26 +19,43 @@
|
|
|
17
19
|
"engines": {
|
|
18
20
|
"node": ">=18"
|
|
19
21
|
},
|
|
20
|
-
"homepage": "https://planpo.st",
|
|
22
|
+
"homepage": "https://planpo.st/mcp",
|
|
21
23
|
"repository": {
|
|
22
24
|
"type": "git",
|
|
23
25
|
"url": "git+https://github.com/lukBB/social-poster-v2.git",
|
|
24
26
|
"directory": "packages/mcp-server"
|
|
25
27
|
},
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://planpo.st/support"
|
|
30
|
+
},
|
|
26
31
|
"keywords": [
|
|
27
32
|
"mcp",
|
|
28
33
|
"model-context-protocol",
|
|
29
|
-
"
|
|
30
|
-
"ai-agent",
|
|
34
|
+
"mcp-server",
|
|
31
35
|
"social-media",
|
|
36
|
+
"social-media-scheduling",
|
|
32
37
|
"scheduling",
|
|
38
|
+
"instagram",
|
|
39
|
+
"tiktok",
|
|
40
|
+
"linkedin",
|
|
41
|
+
"twitter",
|
|
42
|
+
"facebook",
|
|
43
|
+
"threads",
|
|
44
|
+
"youtube",
|
|
45
|
+
"claude",
|
|
46
|
+
"claude-code",
|
|
47
|
+
"cursor",
|
|
48
|
+
"codex",
|
|
49
|
+
"gemini-cli",
|
|
50
|
+
"ai-agent",
|
|
33
51
|
"planpost"
|
|
34
52
|
],
|
|
35
53
|
"scripts": {
|
|
36
54
|
"build": "tsc",
|
|
37
55
|
"dev": "tsc --watch",
|
|
38
56
|
"start": "node dist/index.js",
|
|
39
|
-
"prepublishOnly": "npm run build"
|
|
57
|
+
"prepublishOnly": "npm run build",
|
|
58
|
+
"test": "node --test test/*.test.mjs"
|
|
40
59
|
},
|
|
41
60
|
"dependencies": {
|
|
42
61
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
@@ -44,6 +63,8 @@
|
|
|
44
63
|
},
|
|
45
64
|
"devDependencies": {
|
|
46
65
|
"typescript": "^5.7.0",
|
|
47
|
-
"@types/node": "^22.0.0"
|
|
66
|
+
"@types/node": "^22.0.0",
|
|
67
|
+
"ajv": "^8.17.1",
|
|
68
|
+
"ajv-formats": "^3.0.1"
|
|
48
69
|
}
|
|
49
70
|
}
|