planpost-mcp 0.2.1 → 0.4.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/CHANGELOG.md +49 -0
- package/README.md +228 -16
- package/dist/annotations.d.ts +3 -0
- package/dist/annotations.js +66 -0
- package/dist/index.js +271 -39
- package/dist/oauth.d.ts +73 -0
- package/dist/oauth.js +122 -0
- package/package.json +36 -11
- package/skills/planpost/SKILL.md +114 -0
package/dist/index.js
CHANGED
|
@@ -6,6 +6,8 @@ import { createServer as createHttpServer } from "node:http";
|
|
|
6
6
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
7
|
import { createRequire } from "node:module";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
+
import { annotationsFor } from "./annotations.js";
|
|
10
|
+
import { AccessTokenVerifier, IntrospectionUnavailableError, InvalidTokenError, bearerChallenge, loadOAuthSettings, protectedResourceMetadata, upstreamHeaders, } from "./oauth.js";
|
|
9
11
|
const ENV_API_KEY = process.env.PLANPOST_API_KEY;
|
|
10
12
|
const BASE_URL = process.env.PLANPOST_API_URL || "https://api.planpo.st";
|
|
11
13
|
// Single source of truth for the version: package.json ships in every tarball,
|
|
@@ -13,17 +15,20 @@ const BASE_URL = process.env.PLANPOST_API_URL || "https://api.planpo.st";
|
|
|
13
15
|
// the two drifted on the first release that forgot to update both.
|
|
14
16
|
const { version } = createRequire(import.meta.url)("../package.json");
|
|
15
17
|
const SERVER_INFO = { name: "planpost", version };
|
|
16
|
-
// In HTTP mode each request carries its own
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
// In HTTP mode each request carries its own credential (an API key, or an
|
|
19
|
+
// OAuth grant resolved from the access token); the AsyncLocalStorage context
|
|
20
|
+
// survives across the awaits inside tool handlers, so concurrent requests
|
|
21
|
+
// with different credentials can't bleed into each other. Stdio mode falls
|
|
22
|
+
// back to the env key.
|
|
23
|
+
const requestCredential = new AsyncLocalStorage();
|
|
24
|
+
function resolveCredential() {
|
|
25
|
+
const credential = requestCredential.getStore();
|
|
26
|
+
if (credential)
|
|
27
|
+
return credential;
|
|
28
|
+
if (!ENV_API_KEY) {
|
|
24
29
|
throw new Error("No PlanPost API key: set PLANPOST_API_KEY (stdio) or send Authorization: Bearer pk_... (http)");
|
|
25
30
|
}
|
|
26
|
-
return key;
|
|
31
|
+
return { kind: "key", key: ENV_API_KEY };
|
|
27
32
|
}
|
|
28
33
|
// ─── API Client ───
|
|
29
34
|
async function api(path, options = {}) {
|
|
@@ -32,7 +37,7 @@ async function api(path, options = {}) {
|
|
|
32
37
|
...options,
|
|
33
38
|
headers: {
|
|
34
39
|
"Content-Type": "application/json",
|
|
35
|
-
|
|
40
|
+
...upstreamHeaders(resolveCredential()),
|
|
36
41
|
...options.headers,
|
|
37
42
|
},
|
|
38
43
|
});
|
|
@@ -54,6 +59,18 @@ function formatWarnings(warnings) {
|
|
|
54
59
|
const lines = warnings.map((w) => ` - [${w.code}]${w.platform ? ` ${w.platform}:` : ""} ${w.message}`);
|
|
55
60
|
return `\n\n⚠️ ${warnings.length} warning(s):\n${lines.join("\n")}`;
|
|
56
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Renders the API's `preflight.errors[]` — the checks that would make the
|
|
64
|
+
* post fail at its slot. When present the post was saved as a DRAFT, not
|
|
65
|
+
* scheduled; fix the listed fields and call update_post (or create again).
|
|
66
|
+
*/
|
|
67
|
+
function formatPreflight(preflight) {
|
|
68
|
+
const p = preflight;
|
|
69
|
+
if (!p || !Array.isArray(p.errors) || p.errors.length === 0)
|
|
70
|
+
return "";
|
|
71
|
+
const lines = p.errors.map((e) => ` - [${e.code}] ${e.platform}: ${e.message}${e.fix ? ` Fix: ${e.fix}` : ""}`);
|
|
72
|
+
return `\n\n⛔ Saved as DRAFT — ${p.errors.length} issue(s) block scheduling:\n${lines.join("\n")}`;
|
|
73
|
+
}
|
|
57
74
|
/** Renders the dashboard deep links returned alongside a created/updated post. */
|
|
58
75
|
function formatLinks(links) {
|
|
59
76
|
if (!links || typeof links !== "object")
|
|
@@ -72,9 +89,17 @@ function buildServer() {
|
|
|
72
89
|
return server;
|
|
73
90
|
}
|
|
74
91
|
// ─── Tools ───
|
|
75
|
-
function registerTools(
|
|
92
|
+
function registerTools(target) {
|
|
93
|
+
// Every tool below is registered through this shim so its annotations
|
|
94
|
+
// (title, read-only / destructive hints) come from annotations.ts.
|
|
95
|
+
const server = {
|
|
96
|
+
tool: ((name, description, schema, handler) => target.tool(name, description, schema, annotationsFor(name), handler)),
|
|
97
|
+
};
|
|
76
98
|
server.tool("list_posts", "List your scheduled and published social media posts. Filter by status, platform, campaign, label, post type, or free text.", {
|
|
77
|
-
|
|
99
|
+
// `needs_approval` is a first-class status, not an internal one: a post
|
|
100
|
+
// parked for human sign-off is exactly what an agent needs to be able to
|
|
101
|
+
// find again after submitting it.
|
|
102
|
+
status: z.enum(["draft", "needs_approval", "scheduled", "processing", "published", "partially_published", "failed", "cancelled"]).optional().describe("Filter by post status ('needs_approval' = waiting on human sign-off; 'partially_published' = some platforms live, at least one failed — see platformResults[].error and .fix)"),
|
|
78
103
|
platform: z.string().optional().describe("Filter by platform (instagram, twitter, linkedin, etc.)"),
|
|
79
104
|
campaign: z.string().optional().describe("Filter by campaign tag, e.g. 'garmin-cirqa-2026' (matches utm.campaign; slugified automatically)"),
|
|
80
105
|
label: z.string().optional().describe("Filter by internal label (case-insensitive substring), e.g. 'Garmin CIRQA'"),
|
|
@@ -112,11 +137,11 @@ function registerTools(server) {
|
|
|
112
137
|
const data = await api(`/posts/${id}`);
|
|
113
138
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
114
139
|
});
|
|
115
|
-
server.tool("create_post", "Create a new social media post. Saves as draft or schedules it for a specific time.", {
|
|
140
|
+
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
141
|
content: z.string().describe("Post text content"),
|
|
117
142
|
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
143
|
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' } }"),
|
|
144
|
+
platformSettings: z.record(z.string(), z.any()).optional().describe("Per-platform settings, e.g. { instagram: { isReel: true }, youtube: { title: '...', visibility: 'public' } }. TikTok: a scheduled or published post MUST carry { tiktok: { privacyLevel } } with one of PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY (TikTok makes the user choose who can view each video; without it the API returns 400). Optional tiktok flags: disableComment, disableDuet, disableStitch (default true = off), brandedContent, yourBrand."),
|
|
120
145
|
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
146
|
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
147
|
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."),
|
|
@@ -181,7 +206,7 @@ function registerTools(server) {
|
|
|
181
206
|
: post.scheduledFor
|
|
182
207
|
? `Scheduled for ${post.scheduledFor}`
|
|
183
208
|
: "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)}` }] };
|
|
209
|
+
return { content: [{ type: "text", text: `Post created (${status}).${formatPreflight(data.preflight)}${formatWarnings(data.warnings)}${formatLinks(data.links)}\n\n${JSON.stringify(post, null, 2)}` }] };
|
|
185
210
|
});
|
|
186
211
|
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
212
|
id: z.string().describe("Post ID to preview"),
|
|
@@ -193,7 +218,9 @@ function registerTools(server) {
|
|
|
193
218
|
const media = pl.mediaCount === 0
|
|
194
219
|
? `no media${pl.mediaSource === "override" ? " (text-only override)" : ""}`
|
|
195
220
|
: `${pl.mediaCount} ${pl.mediaCount === 1 ? "item" : "items"} (${pl.media.map((m) => m.filename ?? m.type).join(", ")})`;
|
|
196
|
-
const warn = pl.warnings?.length
|
|
221
|
+
const warn = pl.warnings?.length
|
|
222
|
+
? `\n warnings:\n${pl.warnings.map((w) => ` - [${w.code}] ${w.message}`).join("\n")}`
|
|
223
|
+
: "";
|
|
197
224
|
return [
|
|
198
225
|
`── ${pl.platform.toUpperCase()} ─────────────`,
|
|
199
226
|
` text (${pl.contentSource}): ${limit}${pl.linksClickable ? "" : " [links NOT clickable]"}`,
|
|
@@ -204,7 +231,10 @@ function registerTools(server) {
|
|
|
204
231
|
pl.content.length > 400 ? `${pl.content.slice(0, 400)}…` : pl.content,
|
|
205
232
|
].filter(Boolean).join("\n");
|
|
206
233
|
});
|
|
207
|
-
const
|
|
234
|
+
const blockedLine = p.preflight && p.preflight.ok === false
|
|
235
|
+
? `\n⛔ ${p.preflight.errors.length} issue(s) block scheduling — fix them with update_post:\n${p.preflight.errors.map((e) => ` - [${e.code}] ${e.platform}: ${e.message}${e.fix ? ` Fix: ${e.fix}` : ""}`).join("\n")}\n`
|
|
236
|
+
: "";
|
|
237
|
+
const header = `Preview — postType=${p.postType}${p.label ? ` · ${p.label}` : ""}${p.campaign ? ` · campaign=${p.campaign}` : ""}${p.status ? ` · status=${p.status}` : ""}${blockedLine}`;
|
|
208
238
|
return {
|
|
209
239
|
content: [{
|
|
210
240
|
type: "text",
|
|
@@ -212,7 +242,7 @@ function registerTools(server) {
|
|
|
212
242
|
}],
|
|
213
243
|
};
|
|
214
244
|
});
|
|
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.", {
|
|
245
|
+
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
246
|
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
247
|
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
248
|
}, async ({ posts, defaults }) => {
|
|
@@ -222,7 +252,9 @@ function registerTools(server) {
|
|
|
222
252
|
const data = await api("/posts/bulk", { method: "POST", body: JSON.stringify(body) });
|
|
223
253
|
const d = data.data;
|
|
224
254
|
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))` : ""}
|
|
255
|
+
? ` #${r.index} OK ${r.label ?? "(no label)"} → ${r.id} [${r.status}]${Array.isArray(r.warnings) && r.warnings.length ? ` (${r.warnings.length} warning(s))` : ""}${Array.isArray(r.preflightErrors) && r.preflightErrors.length
|
|
256
|
+
? `\n ⛔ saved as draft: ${r.preflightErrors.map((e) => `[${e.code}] ${e.message}${e.fix ? ` Fix: ${e.fix}` : ""}`).join(" | ")}`
|
|
257
|
+
: ""}`
|
|
226
258
|
: ` #${r.index} FAILED ${r.error}`);
|
|
227
259
|
return {
|
|
228
260
|
content: [{
|
|
@@ -280,12 +312,12 @@ function registerTools(server) {
|
|
|
280
312
|
const data = await api(`/media?${params}`);
|
|
281
313
|
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
282
314
|
});
|
|
283
|
-
server.tool("update_post", "Update a draft or scheduled post.", {
|
|
315
|
+
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
316
|
id: z.string().describe("Post ID to update"),
|
|
285
317
|
content: z.string().optional().describe("New post text content"),
|
|
286
318
|
connectionIds: z.array(z.string()).optional().describe("New connection IDs"),
|
|
287
319
|
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 } })"),
|
|
320
|
+
platformSettings: z.record(z.string(), z.any()).optional().describe("Per-platform settings (e.g. { instagram: { isReel: true } }). TikTok posts need { tiktok: { privacyLevel } } (PUBLIC_TO_EVERYONE | MUTUAL_FOLLOW_FRIENDS | FOLLOWER_OF_CREATOR | SELF_ONLY) before they can be scheduled — this is how to repair a TikTok post that was created without one."),
|
|
289
321
|
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
322
|
threadSegments: z.array(z.string()).optional().describe("Ordered tweet segments for a thread (postType='thread'). 2–25 segments, each 1–280 characters."),
|
|
291
323
|
label: z.string().nullable().optional().describe("Internal working name shown in the dashboard. Pass null to clear. Never published."),
|
|
@@ -348,7 +380,7 @@ function registerTools(server) {
|
|
|
348
380
|
if (timezone !== undefined)
|
|
349
381
|
body.timezone = timezone;
|
|
350
382
|
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)}` }] };
|
|
383
|
+
return { content: [{ type: "text", text: `Post updated (${data.data?.status ?? "unknown"}).${formatPreflight(data.preflight)}${formatWarnings(data.warnings)}${formatLinks(data.links)}\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
352
384
|
});
|
|
353
385
|
server.tool("delete_post", "Delete a post.", {
|
|
354
386
|
id: z.string().describe("Post ID to delete"),
|
|
@@ -356,7 +388,85 @@ function registerTools(server) {
|
|
|
356
388
|
await api(`/posts/${id}`, { method: "DELETE" });
|
|
357
389
|
return { content: [{ type: "text", text: `Post ${id} deleted.` }] };
|
|
358
390
|
});
|
|
359
|
-
server.tool("
|
|
391
|
+
server.tool("retry_failed", "Retry the platforms that failed on a failed or partially_published post. Only failures classified transient (platform outage, rate limit, network) are re-queued; failures that need the user — reconnect an account, top up X API credits, choose TikTok visibility — or that the payload caused are refused and returned with their fix. Platforms that already went out are never re-posted. Check get_post → platformResults[].errorClass / .fix first.", {
|
|
392
|
+
id: z.string().describe("Post ID with status failed or partially_published"),
|
|
393
|
+
}, async ({ id }) => {
|
|
394
|
+
let data;
|
|
395
|
+
try {
|
|
396
|
+
data = await api(`/posts/${id}/retry`, { method: "POST" });
|
|
397
|
+
}
|
|
398
|
+
catch (e) {
|
|
399
|
+
return { content: [{ type: "text", text: `Retry refused: ${e?.message ?? e}` }] };
|
|
400
|
+
}
|
|
401
|
+
const retried = Array.isArray(data.retried) && data.retried.length ? data.retried.join(", ") : "nothing";
|
|
402
|
+
const blocked = Array.isArray(data.blocked) && data.blocked.length
|
|
403
|
+
? `\n\nNot retried (a retry would fail the same way):\n${data.blocked.map((b) => ` - ${b.provider}: [${b.code}] ${b.error ?? ""}${b.fix ? ` Fix: ${b.fix}` : ""}`).join("\n")}`
|
|
404
|
+
: "";
|
|
405
|
+
return { content: [{ type: "text", text: `Retrying: ${retried}.${blocked}${formatLinks(data.links)}\n\n${JSON.stringify(data.data, null, 2)}` }] };
|
|
406
|
+
});
|
|
407
|
+
server.tool("get_delivery_report", "The morning check: everything that needs the user's attention about delivery. Posts that failed or partially failed in the last `days` days with each platform's error and fix, connections whose health went dead or degraded, pending approvals whose slot is within 48 h or already passed, approvals that expired (their posts are drafts again), scheduled posts in the next 7 days that would fail preflight if dispatched now, auto-import content sources that stopped fetching, and posting queues with free slots this week while drafts wait. Same data as the 08:00 digest e-mail. Empty `hasIssues: false` means nothing to do.", {
|
|
408
|
+
days: z.number().int().min(1).max(30).optional().describe("Look-back window in days for failures and connection changes (default 1)"),
|
|
409
|
+
}, async ({ days }) => {
|
|
410
|
+
const data = await api(`/delivery-report?days=${days ?? 1}`);
|
|
411
|
+
const r = data.data ?? {};
|
|
412
|
+
const label = (p) => p;
|
|
413
|
+
const lines = [];
|
|
414
|
+
if (!r.hasIssues) {
|
|
415
|
+
lines.push(`All clear for the last ${r.windowDays ?? days ?? 1} day(s): no failed posts, no connection changes, no approvals due or expired, no upcoming post fails preflight, no stale content source, no empty queue slots with drafts waiting.`);
|
|
416
|
+
}
|
|
417
|
+
else {
|
|
418
|
+
if (Array.isArray(r.failedPosts) && r.failedPosts.length) {
|
|
419
|
+
lines.push(`Did not go out (${r.failedPosts.length}):`);
|
|
420
|
+
for (const p of r.failedPosts) {
|
|
421
|
+
lines.push(` - ${p.postId} [${p.status}${p.status === "partially_published" ? ` ${p.liveCount}/${p.targetCount} live` : ""}] ${p.content}`);
|
|
422
|
+
for (const f of p.failures ?? []) {
|
|
423
|
+
lines.push(` ${label(f.provider)}: [${f.errorCode ?? f.errorClass ?? "failed"}] ${f.error ?? ""}${f.fix ? ` Fix: ${f.fix}` : ""}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (Array.isArray(r.connections) && r.connections.length) {
|
|
428
|
+
lines.push(`Connections (${r.connections.length}):`);
|
|
429
|
+
for (const c of r.connections) {
|
|
430
|
+
lines.push(` - ${c.provider} @${c.username} is ${c.status}${c.reason ? ` [${c.reason}]` : ""}${c.lastError ? `: ${c.lastError}` : ""}${c.status === "dead" ? " Fix: reconnect on /connections." : ""}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (Array.isArray(r.approvals) && r.approvals.length) {
|
|
434
|
+
lines.push(`Approvals (${r.approvals.length}):`);
|
|
435
|
+
for (const a of r.approvals) {
|
|
436
|
+
lines.push(` - ${a.approvalId} post ${a.postId} ${a.overdue ? "SLOT PASSED" : `due ${a.scheduledFor}`}${a.reviewerEmail ? ` (reviewer ${a.reviewerEmail})` : ""}: ${a.content}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (Array.isArray(r.expiredApprovals) && r.expiredApprovals.length) {
|
|
440
|
+
lines.push(`Approvals that expired, posts back to draft (${r.expiredApprovals.length}):`);
|
|
441
|
+
for (const a of r.expiredApprovals) {
|
|
442
|
+
lines.push(` - ${a.approvalId} post ${a.postId}${a.scheduledFor ? ` (slot was ${a.scheduledFor})` : ""}${a.reviewerEmail ? ` reviewer ${a.reviewerEmail}` : ""}: ${a.content} Fix: reschedule with update_post and resubmit, or publish_post.`);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
if (Array.isArray(r.atRisk) && r.atRisk.length) {
|
|
446
|
+
lines.push(`Scheduled posts that will fail (${r.atRisk.length}):`);
|
|
447
|
+
for (const p of r.atRisk) {
|
|
448
|
+
lines.push(` - ${p.postId} at ${p.scheduledFor}: ${p.content}`);
|
|
449
|
+
for (const i of p.issues ?? []) {
|
|
450
|
+
lines.push(` ${i.platform}: [${i.code}] ${i.message}${i.fix ? ` Fix: ${i.fix}` : ""}`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (Array.isArray(r.staleSources) && r.staleSources.length) {
|
|
456
|
+
lines.push(`Content sources that stopped importing (${r.staleSources.length}):`);
|
|
457
|
+
for (const src of r.staleSources) {
|
|
458
|
+
lines.push(` - ${src.sourceId} ${src.name} (${src.frequency}, last fetch ${src.lastFetchedAt ?? "never"}, ${src.staleHours}h ago). Fix: fetch_source_content, then check the URL/credentials if it fails.`);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
if (Array.isArray(r.staleQueues) && r.staleQueues.length) {
|
|
462
|
+
lines.push(`Posting queues with empty slots while drafts wait (${r.staleQueues.length}):`);
|
|
463
|
+
for (const q of r.staleQueues) {
|
|
464
|
+
lines.push(` - ${q.groupName ? `group ${q.groupName} (${q.groupId})` : "global queue"}: ${q.emptySlots} of ${q.totalSlots} slots free in the next ${q.horizonDays} days, ${q.drafts} draft(s). Fix: suggest_schedule / update_post with a scheduledFor from get_posting_schedule.`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
return { content: [{ type: "text", text: `${lines.join("\n")}\n\n${JSON.stringify(r, null, 2)}` }] };
|
|
468
|
+
});
|
|
469
|
+
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
470
|
id: z.string().describe("Post ID to publish now"),
|
|
361
471
|
}, async ({ id }) => {
|
|
362
472
|
const data = await api(`/posts/${id}/publish`, { method: "POST" });
|
|
@@ -365,8 +475,21 @@ function registerTools(server) {
|
|
|
365
475
|
server.tool("list_connections", "List all connected social media accounts (Instagram, Twitter, LinkedIn, etc.).", {}, async () => {
|
|
366
476
|
const data = await api("/connections");
|
|
367
477
|
const connections = data.data;
|
|
368
|
-
|
|
369
|
-
|
|
478
|
+
// Health comes from the publishing worker and token refresher: a `dead`
|
|
479
|
+
// account fails preflight for every post that targets it until `reason`
|
|
480
|
+
// is fixed, so say so next to the name instead of burying it in JSON.
|
|
481
|
+
const healthTag = (c) => {
|
|
482
|
+
const h = c.health;
|
|
483
|
+
if (!h || h.status === "healthy")
|
|
484
|
+
return "";
|
|
485
|
+
if (h.status === "degraded")
|
|
486
|
+
return " ⚠️ last publish failed, will retry";
|
|
487
|
+
return ` ⛔ cannot publish${h.reason ? ` [${h.reason}]` : ""}${h.lastError ? `: ${String(h.lastError).slice(0, 120)}` : ""}`;
|
|
488
|
+
};
|
|
489
|
+
const summary = connections.map((c) => `${c.provider} — ${c.metadata?.accountName || c.providerUsername} (ID: ${c.id})${healthTag(c)}`).join("\n");
|
|
490
|
+
const dead = connections.filter((c) => c.health?.status === "dead").length;
|
|
491
|
+
const headline = `${connections.length} connected accounts${dead ? `, ${dead} cannot publish until fixed` : ""}:`;
|
|
492
|
+
return { content: [{ type: "text", text: `${headline}\n\n${summary}\n\nFull data:\n${JSON.stringify(connections, null, 2)}` }] };
|
|
370
493
|
});
|
|
371
494
|
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
495
|
const data = await api("/account-groups");
|
|
@@ -774,6 +897,31 @@ function registerTools(server) {
|
|
|
774
897
|
});
|
|
775
898
|
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
776
899
|
});
|
|
900
|
+
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 () => {
|
|
901
|
+
const data = await api("/posting-queue/queues");
|
|
902
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
903
|
+
});
|
|
904
|
+
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.", {
|
|
905
|
+
groupId: z
|
|
906
|
+
.string()
|
|
907
|
+
.optional()
|
|
908
|
+
.describe("Account group id (from list_account_groups); omit for the global schedule"),
|
|
909
|
+
slots: z
|
|
910
|
+
.array(z.object({
|
|
911
|
+
day: z.number().int().min(1).max(7).describe("ISO weekday: 1=Monday .. 7=Sunday"),
|
|
912
|
+
time: z
|
|
913
|
+
.string()
|
|
914
|
+
.regex(/^([01]\d|2[0-3]):[0-5]\d$/)
|
|
915
|
+
.describe("24h time 'HH:MM' in the user's timezone, e.g. '09:30'"),
|
|
916
|
+
}))
|
|
917
|
+
.describe("The complete weekly slot list to store"),
|
|
918
|
+
}, async ({ groupId, slots }) => {
|
|
919
|
+
const data = await api(`/posting-queue/queues/${encodeURIComponent(groupId ?? "global")}`, {
|
|
920
|
+
method: "PUT",
|
|
921
|
+
body: JSON.stringify({ slots }),
|
|
922
|
+
});
|
|
923
|
+
return { content: [{ type: "text", text: JSON.stringify(data.data, null, 2) }] };
|
|
924
|
+
});
|
|
777
925
|
}
|
|
778
926
|
// ─── Start ───
|
|
779
927
|
async function startStdio() {
|
|
@@ -807,36 +955,119 @@ function readBody(req) {
|
|
|
807
955
|
* Remote/streamable-HTTP mode: `planpost-mcp --http [--port 3020]`.
|
|
808
956
|
*
|
|
809
957
|
* Stateless — every POST /mcp builds a fresh server + transport, and the
|
|
810
|
-
* caller's
|
|
811
|
-
*
|
|
812
|
-
*
|
|
813
|
-
*
|
|
814
|
-
*
|
|
958
|
+
* caller's credential is scoped to that request via AsyncLocalStorage. That
|
|
959
|
+
* makes one hosted instance safely multi-tenant.
|
|
960
|
+
*
|
|
961
|
+
* Two ways to authenticate:
|
|
962
|
+
* - `Authorization: Bearer pk_...` — a PlanPost API key, always accepted
|
|
963
|
+
* (e.g. `claude mcp add --transport http planpost https://host/mcp
|
|
964
|
+
* --header "Authorization: Bearer pk_..."`).
|
|
965
|
+
* - OAuth, when configured (see oauth.ts): a request without credentials is
|
|
966
|
+
* answered 401 with a pointer to the protected-resource metadata, the
|
|
967
|
+
* client signs the user in at planpo.st, and comes back with a token.
|
|
815
968
|
*/
|
|
816
969
|
async function startHttp(port, host) {
|
|
970
|
+
const oauth = loadOAuthSettings(process.env, BASE_URL);
|
|
971
|
+
const verifier = oauth ? new AccessTokenVerifier(oauth) : null;
|
|
817
972
|
const httpServer = createHttpServer(async (req, res) => {
|
|
818
|
-
|
|
819
|
-
|
|
973
|
+
// An API host must never rank: Google indexed the bare /mcp 405 JSON as a
|
|
974
|
+
// page under the brand. The header, not robots.txt, is the fix on purpose:
|
|
975
|
+
// a crawler has to be able to fetch the URL to see the noindex, so
|
|
976
|
+
// robots.txt below allows crawling and this header does the de-indexing.
|
|
977
|
+
res.setHeader("X-Robots-Tag", "noindex, nofollow");
|
|
978
|
+
const fail = (status, message, headers = {}) => {
|
|
979
|
+
// charset is load-bearing: without it, any non-ASCII byte in a message
|
|
980
|
+
// renders as mojibake in browsers that assume latin-1 for JSON.
|
|
981
|
+
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
|
|
820
982
|
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
|
|
821
983
|
};
|
|
822
984
|
try {
|
|
823
985
|
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
|
|
824
986
|
if (url.pathname === "/health") {
|
|
825
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
987
|
+
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
|
826
988
|
res.end(JSON.stringify({ status: "ok" }));
|
|
827
989
|
return;
|
|
828
990
|
}
|
|
991
|
+
if (url.pathname === "/robots.txt") {
|
|
992
|
+
// Allow crawling so the X-Robots-Tag noindex is actually seen.
|
|
993
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
994
|
+
res.end("User-agent: *\nDisallow:\n");
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (url.pathname === "/" && (req.method === "GET" || req.method === "HEAD")) {
|
|
998
|
+
// A human (or crawler) landing on the bare host gets the docs page
|
|
999
|
+
// instead of a JSON error.
|
|
1000
|
+
res.writeHead(302, { Location: "https://planpo.st/mcp" });
|
|
1001
|
+
res.end();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
if (url.pathname === "/.well-known/openai-apps-challenge" && process.env.OPENAI_APPS_CHALLENGE) {
|
|
1005
|
+
// Domain verification for the ChatGPT plugin directory: OpenAI issues
|
|
1006
|
+
// a token at submission time and fetches it from here as plain text.
|
|
1007
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
1008
|
+
res.end(process.env.OPENAI_APPS_CHALLENGE);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (oauth && url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
|
|
1012
|
+
// Served at both the path-suffixed and the root well-known URL:
|
|
1013
|
+
// clients try them in that order when the 401 challenge is missing.
|
|
1014
|
+
const suffix = url.pathname.slice("/.well-known/oauth-protected-resource".length);
|
|
1015
|
+
if (suffix !== "" && suffix !== oauth.resourceUrl.pathname) {
|
|
1016
|
+
return fail(404, "Not found. The MCP endpoint is /mcp");
|
|
1017
|
+
}
|
|
1018
|
+
res.writeHead(200, {
|
|
1019
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
1020
|
+
"Access-Control-Allow-Origin": "*",
|
|
1021
|
+
"Cache-Control": "public, max-age=3600",
|
|
1022
|
+
});
|
|
1023
|
+
res.end(JSON.stringify(protectedResourceMetadata(oauth)));
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
829
1026
|
if (url.pathname !== "/mcp")
|
|
830
|
-
return fail(404, "Not found
|
|
1027
|
+
return fail(404, "Not found. The MCP endpoint is /mcp");
|
|
831
1028
|
if (req.method !== "POST") {
|
|
832
1029
|
// Stateless mode: no SSE resumption stream, no sessions to delete.
|
|
833
|
-
return fail(405, "Method not allowed
|
|
1030
|
+
return fail(405, "Method not allowed. This stateless transport accepts POST only; setup guide at https://planpo.st/mcp");
|
|
834
1031
|
}
|
|
835
1032
|
const auth = req.headers.authorization || "";
|
|
836
|
-
const
|
|
837
|
-
|
|
1033
|
+
const bearer = /^Bearer /i.test(auth) ? auth.slice(7).trim() : "";
|
|
1034
|
+
let credential;
|
|
1035
|
+
if (bearer.startsWith("pk_")) {
|
|
1036
|
+
credential = { kind: "key", key: bearer };
|
|
1037
|
+
}
|
|
1038
|
+
else if (!oauth || !verifier) {
|
|
838
1039
|
return fail(401, "Provide your PlanPost API key: Authorization: Bearer pk_...");
|
|
839
1040
|
}
|
|
1041
|
+
else if (!bearer) {
|
|
1042
|
+
// The challenge is what starts the OAuth flow in the client.
|
|
1043
|
+
return fail(401, "Sign in to PlanPost to use this server, or send an API key: Authorization: Bearer pk_...", { "WWW-Authenticate": bearerChallenge(oauth) });
|
|
1044
|
+
}
|
|
1045
|
+
else {
|
|
1046
|
+
try {
|
|
1047
|
+
const authInfo = await verifier.verify(bearer);
|
|
1048
|
+
// The transport hands this to tool handlers as `extra.authInfo`.
|
|
1049
|
+
req.auth = authInfo;
|
|
1050
|
+
credential = {
|
|
1051
|
+
kind: "grant",
|
|
1052
|
+
grantId: String(authInfo.extra?.grantId),
|
|
1053
|
+
serviceSecret: oauth.serviceSecret,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
catch (err) {
|
|
1057
|
+
if (err instanceof InvalidTokenError) {
|
|
1058
|
+
return fail(401, err.message, {
|
|
1059
|
+
"WWW-Authenticate": bearerChallenge(oauth, err.message),
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
if (err instanceof IntrospectionUnavailableError) {
|
|
1063
|
+
// Not the client's fault: a 401 here would make it throw away a
|
|
1064
|
+
// good token and send the user through sign-in again.
|
|
1065
|
+
console.error("[planpost-mcp]", err.message);
|
|
1066
|
+
return fail(503, "PlanPost sign-in is temporarily unavailable. Try again shortly.");
|
|
1067
|
+
}
|
|
1068
|
+
throw err;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
840
1071
|
const body = await readBody(req);
|
|
841
1072
|
const server = buildServer();
|
|
842
1073
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -847,7 +1078,7 @@ async function startHttp(port, host) {
|
|
|
847
1078
|
server.close();
|
|
848
1079
|
});
|
|
849
1080
|
await server.connect(transport);
|
|
850
|
-
await
|
|
1081
|
+
await requestCredential.run(credential, () => transport.handleRequest(req, res, body));
|
|
851
1082
|
}
|
|
852
1083
|
catch (err) {
|
|
853
1084
|
console.error("[planpost-mcp] request error:", err);
|
|
@@ -856,7 +1087,8 @@ async function startHttp(port, host) {
|
|
|
856
1087
|
}
|
|
857
1088
|
});
|
|
858
1089
|
httpServer.listen(port, host, () => {
|
|
859
|
-
console.log(`planpost-mcp listening on http://${host}:${port}/mcp (streamable HTTP, stateless
|
|
1090
|
+
console.log(`planpost-mcp listening on http://${host}:${port}/mcp (streamable HTTP, stateless; ` +
|
|
1091
|
+
(oauth ? `OAuth via ${oauth.issuer} + API keys)` : "API keys only)"));
|
|
860
1092
|
});
|
|
861
1093
|
}
|
|
862
1094
|
async function main() {
|
package/dist/oauth.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
|
2
|
+
import type { OAuthProtectedResourceMetadata } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* OAuth for the hosted (streamable HTTP) server, per the MCP authorization
|
|
5
|
+
* spec. This process is the OAuth *resource server*; the PlanPost API is the
|
|
6
|
+
* *authorization server* (it runs /oauth/authorize, /token, /register).
|
|
7
|
+
*
|
|
8
|
+
* What happens here:
|
|
9
|
+
* - `/.well-known/oauth-protected-resource[/mcp]` tells clients where to
|
|
10
|
+
* sign in (RFC 9728).
|
|
11
|
+
* - A request with no or a bad token gets `401` + `WWW-Authenticate: Bearer
|
|
12
|
+
* resource_metadata="…"`, which is how a client discovers that document.
|
|
13
|
+
* - A `ppo_at_…` access token is validated by introspection (RFC 7662) and
|
|
14
|
+
* must have been issued for THIS server (`aud`), per RFC 8707.
|
|
15
|
+
*
|
|
16
|
+
* The access token is never forwarded to the API ("token passthrough" is
|
|
17
|
+
* forbidden by the spec). Tool calls go upstream with this server's own
|
|
18
|
+
* service secret plus the id of the grant they act for; see `Credential`.
|
|
19
|
+
*
|
|
20
|
+
* OAuth is on only when all three env vars are set, so a self-hosted
|
|
21
|
+
* `planpost-mcp --http` keeps working with API keys alone.
|
|
22
|
+
*/
|
|
23
|
+
export interface OAuthSettings {
|
|
24
|
+
/** Public URL of this server's MCP endpoint, e.g. https://mcp.planpo.st/mcp */
|
|
25
|
+
resourceUrl: URL;
|
|
26
|
+
/** Authorization server issuer, e.g. https://api.planpo.st */
|
|
27
|
+
issuer: string;
|
|
28
|
+
/** Where to reach the API for introspection (usually the same host). */
|
|
29
|
+
apiUrl: string;
|
|
30
|
+
/** Shared with the API (its MCP_SERVICE_SECRET). */
|
|
31
|
+
serviceSecret: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function loadOAuthSettings(env: NodeJS.ProcessEnv, apiUrl: string): OAuthSettings | null;
|
|
34
|
+
/**
|
|
35
|
+
* How a request authenticates upstream: a user's own API key, or an OAuth
|
|
36
|
+
* grant this server vouches for with its service secret.
|
|
37
|
+
*/
|
|
38
|
+
export type Credential = {
|
|
39
|
+
kind: "key";
|
|
40
|
+
key: string;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "grant";
|
|
43
|
+
grantId: string;
|
|
44
|
+
serviceSecret: string;
|
|
45
|
+
};
|
|
46
|
+
export declare function upstreamHeaders(credential: Credential): Record<string, string>;
|
|
47
|
+
/** RFC 9728 §3.1: the well-known path carries the resource's own path as a suffix. */
|
|
48
|
+
export declare function resourceMetadataUrl(settings: OAuthSettings): string;
|
|
49
|
+
export declare function protectedResourceMetadata(settings: OAuthSettings): OAuthProtectedResourceMetadata;
|
|
50
|
+
/**
|
|
51
|
+
* The `WWW-Authenticate` value for a 401. No `scope`: the client then asks
|
|
52
|
+
* for the authorization server's `scopes_supported`, and the user narrows
|
|
53
|
+
* nothing by accident.
|
|
54
|
+
*/
|
|
55
|
+
export declare function bearerChallenge(settings: OAuthSettings, invalidTokenReason?: string): string;
|
|
56
|
+
/** The token is not valid here: answer 401 with a challenge. */
|
|
57
|
+
export declare class InvalidTokenError extends Error {
|
|
58
|
+
}
|
|
59
|
+
/** The authorization server could not be asked: answer 503, not 401. */
|
|
60
|
+
export declare class IntrospectionUnavailableError extends Error {
|
|
61
|
+
}
|
|
62
|
+
export declare class AccessTokenVerifier {
|
|
63
|
+
private readonly settings;
|
|
64
|
+
private readonly fetchImpl;
|
|
65
|
+
private cache;
|
|
66
|
+
constructor(settings: OAuthSettings, fetchImpl?: typeof fetch);
|
|
67
|
+
/**
|
|
68
|
+
* Introspects the token and checks it was minted for this server. Results
|
|
69
|
+
* are cached briefly; revocation still bites at once, because the API looks
|
|
70
|
+
* the grant up again on every tool call.
|
|
71
|
+
*/
|
|
72
|
+
verify(token: string): Promise<AuthInfo>;
|
|
73
|
+
}
|