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/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 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) {
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
- Authorization: `Bearer ${resolveApiKey()}`,
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(server) {
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
- status: z.enum(["draft", "scheduled", "processing", "published", "failed", "cancelled"]).optional().describe("Filter by post status"),
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 ? `\n warnings: ${pl.warnings.map((w) => w.code).join(", ")}` : "";
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 header = `Preview — postType=${p.postType}${p.label ? ` · ${p.label}` : ""}${p.campaign ? ` · campaign=${p.campaign}` : ""}`;
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("publish_post", "Publish a draft or scheduled post immediately to all connected platforms.", {
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
- 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)}` }] };
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 `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.
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
- const fail = (status, message) => {
819
- res.writeHead(status, { "Content-Type": "application/json" });
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 — MCP endpoint is /mcp");
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 — stateless transport accepts POST only");
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 key = auth.startsWith("Bearer ") ? auth.slice(7) : "";
837
- if (!key.startsWith("pk_")) {
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 requestApiKey.run(key, () => transport.handleRequest(req, res, body));
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() {
@@ -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
+ }