planpost-mcp 0.3.0 → 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 ADDED
@@ -0,0 +1,49 @@
1
+ # Changelog
2
+
3
+ All notable changes to `planpost-mcp`. Versions follow [semver](https://semver.org).
4
+
5
+ ## 0.4.0 — 2026-11-09
6
+
7
+ ### Added
8
+ - **OAuth sign-in for the hosted server.** With `--http`, the server is now an
9
+ OAuth 2.1 resource server per the MCP authorization spec (revision
10
+ 2026-07-28): protected-resource metadata at
11
+ `/.well-known/oauth-protected-resource`, a `401` +
12
+ `WWW-Authenticate: Bearer resource_metadata="…"` challenge for
13
+ unauthenticated requests, access-token validation by introspection with an
14
+ audience check, and no token passthrough. Clients that support remote MCP
15
+ authorization (Claude, ChatGPT, Cursor, Gemini CLI, VS Code) can connect to
16
+ `https://mcp.planpo.st/mcp` with no key. Enabled by
17
+ `PLANPOST_MCP_PUBLIC_URL` + `PLANPOST_MCP_SERVICE_SECRET`; without them the
18
+ server behaves exactly as before.
19
+ - **Tool annotations** on all 49 tools: `title`, `readOnlyHint`,
20
+ `destructiveHint`, `idempotentHint`, `openWorldHint`.
21
+ - **Agent bundles** in the repository: `skills/planpost/SKILL.md`
22
+ (`npx skills add`), `gemini-extension.json` + `GEMINI.md` (Gemini CLI
23
+ extension), `.claude-plugin/` (Claude Code plugin and marketplace) and
24
+ `.cursor-plugin/` + `mcp.json` (Cursor plugin).
25
+ - Tests for hosted OAuth mode and for manifest consistency; `npm test` now
26
+ builds first.
27
+
28
+ ### Changed
29
+ - `server.json`: the hosted remote's `Authorization` header is optional
30
+ (omit it to sign in with OAuth).
31
+ - API-key authentication (`Authorization: Bearer pk_…` over HTTP,
32
+ `PLANPOST_API_KEY` over stdio) is unchanged.
33
+
34
+ ## 0.3.0 — 2026-11-08
35
+
36
+ - Listed on the official MCP Registry as `io.github.lukBB/planpost`
37
+ (`server.json`, `mcpName`).
38
+ - Hosted-first README: install snippets for Claude Code, Claude Desktop,
39
+ Cursor, Codex CLI and Gemini CLI.
40
+ - `retry_failed` and `get_delivery_report` tools; publish failures carry an
41
+ error class and a suggested fix.
42
+ - `npm test`: registry schema validation and a stdio boot test.
43
+
44
+ ## 0.2.x
45
+
46
+ - Streamable HTTP mode (`--http`) with per-request API keys, powering
47
+ `https://mcp.planpo.st/mcp`.
48
+ - Account groups, approvals, smart scheduling, carousels, video rendering,
49
+ content sources, brand voice, revenue overview.
package/README.md CHANGED
@@ -1,9 +1,42 @@
1
1
  # planpost-mcp
2
2
 
3
- MCP (Model Context Protocol) server for [PlanPost](https://planpo.st) — lets Claude and other AI agents schedule social media posts, create videos and carousels, manage content sources, and read analytics and revenue data by talking to your PlanPost account.
3
+ MCP (Model Context Protocol) server for [PlanPost](https://planpo.st), the agentic social scheduler with revenue attribution. It lets Claude, ChatGPT, Cursor, Gemini CLI and other AI agents schedule social media posts, create videos and carousels, manage content sources, and read analytics and revenue data by talking to your PlanPost account.
4
4
 
5
5
  ## Quick start
6
6
 
7
+ ### The short way: sign in
8
+
9
+ Add `https://mcp.planpo.st/mcp` to your agent as a remote MCP server (a
10
+ "connector" in Claude and ChatGPT) and leave the authentication fields empty.
11
+ The first time the agent uses it, a browser opens on planpo.st: log in, review
12
+ what the agent may do, and choose whether its posts wait for your approval.
13
+ No key to create or paste.
14
+
15
+ ```bash
16
+ claude mcp add --transport http planpost https://mcp.planpo.st/mcp
17
+ ```
18
+
19
+ ```json
20
+ { "mcpServers": { "planpost": { "url": "https://mcp.planpo.st/mcp" } } }
21
+ ```
22
+
23
+ Connected agents are listed under **Settings → API Keys → Connected apps**,
24
+ where you can remove one at any time. They use the same permissions and the
25
+ same monthly call allowance as API keys.
26
+
27
+ The repository also ships ready-made bundles: a Claude Code plugin
28
+ (`.claude-plugin/`), a Cursor plugin (`.cursor-plugin/`), a Gemini CLI
29
+ extension (`gemini-extension.json`) and an agent skill:
30
+
31
+ ```bash
32
+ npx skills add lukBB/planpost-mcp --skill planpost
33
+ gemini extensions install https://github.com/lukBB/planpost-mcp
34
+ claude plugin marketplace add lukBB/planpost-mcp && claude plugin install planpost@planpost-plugins
35
+ ```
36
+
37
+ The rest of this section is the API-key route: use it for clients without
38
+ OAuth support, for CI, or when you want a key limited to a few permissions.
39
+
7
40
  ### 1. Get an API key
8
41
 
9
42
  In the PlanPost dashboard go to **Settings → API Keys → Create Key** and select the permissions you need (see the table below). The key (`pk_live_…`) is shown **once** — copy it.
@@ -15,7 +48,7 @@ In the PlanPost dashboard go to **Settings → API Keys → Create Key** and sel
15
48
  Two ways to connect. **Hosted** is the default: point your client at
16
49
  `https://mcp.planpo.st/mcp`, nothing to install, your key travels as a header.
17
50
  **Local** runs the same server on your machine with `npx`, so the key never
18
- leaves it. Both expose the same 47 tools.
51
+ leaves it. Both expose the same 49 tools.
19
52
 
20
53
  Replace `pk_live_xxx` with your key throughout.
21
54
 
@@ -131,11 +164,27 @@ Behind a reverse proxy, bind to loopback so the port isn't reachable directly:
131
164
  HOST=127.0.0.1 npx -y planpost-mcp --http --port 3020
132
165
  ```
133
166
 
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.
167
+ The transport is stateless (streamable HTTP); credentials are read from every
168
+ request and scoped to it, so one hosted instance serves many users safely. Put
169
+ it behind HTTPS in production. `--host` / `HOST` default to `0.0.0.0`, so
170
+ containers and LAN use work without extra flags.
171
+
172
+ A self-hosted endpoint accepts API keys (`Authorization: Bearer pk_...`) out of
173
+ the box. OAuth sign-in is switched on by three variables, and needs a PlanPost
174
+ API that runs the authorization server (`/oauth/*`):
175
+
176
+ | Variable | Description |
177
+ |---|---|
178
+ | `PLANPOST_MCP_PUBLIC_URL` | Public URL of this endpoint, e.g. `https://mcp.example.com/mcp`. Tokens are issued for exactly this URL. |
179
+ | `PLANPOST_MCP_SERVICE_SECRET` | 32+ random characters shared with the API (`MCP_SERVICE_SECRET` there). |
180
+ | `PLANPOST_OAUTH_ISSUER` | Authorization server origin. Defaults to `PLANPOST_API_URL`. |
181
+ | `OPENAI_APPS_CHALLENGE` | Optional. Token served at `/.well-known/openai-apps-challenge` for ChatGPT directory domain verification. |
182
+
183
+ With those set the server follows the MCP authorization spec: it serves
184
+ `/.well-known/oauth-protected-resource`, answers unauthenticated requests with
185
+ `401` and a `WWW-Authenticate: Bearer resource_metadata="…"` challenge,
186
+ validates access tokens by introspection, checks they were issued for this
187
+ endpoint, and never forwards them upstream.
139
188
 
140
189
  ### 3. Talk to it
141
190
 
@@ -151,12 +200,12 @@ extra flags.
151
200
 
152
201
  | Variable | Required | Description |
153
202
  |---|---|---|
154
- | `PLANPOST_API_KEY` | ✅ | API key from Settings → API Keys (`pk_live_…`) |
203
+ | `PLANPOST_API_KEY` | stdio only | API key from Settings → API Keys (`pk_live_…`). Not needed with `--http`, where each request brings its own credentials. |
155
204
  | `PLANPOST_API_URL` | — | API base URL (default `https://api.planpo.st`) |
156
205
 
157
206
  ## Tools
158
207
 
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.
208
+ 49 tools, grouped below. The permission column is the scope a tool needs, whether it comes from an API key or from OAuth sign-in; a credential without the scope gets a clear error rather than a silent failure. Every tool carries MCP annotations (a title plus read-only, destructive and open-world hints) so clients know which calls to confirm.
160
209
 
161
210
  ### Posts & publishing
162
211
 
@@ -168,6 +217,8 @@ extra flags.
168
217
  | `update_post` | `posts:write` | Update a draft/scheduled post |
169
218
  | `delete_post` | `posts:write` | Delete a post |
170
219
  | `publish_post` | `posts:write` | Publish immediately |
220
+ | `retry_failed` | `posts:write` | Re-queue the platforms that failed for a transient reason; never re-posts one that went out |
221
+ | `get_delivery_report` | `posts:read` | The morning check: recent failed or partly failed posts with each platform's error and fix, unhealthy connections, approvals running late |
171
222
  | `preview_post` | `posts:read` | Show exactly what each platform will receive (text after overrides, attachments, warnings) |
172
223
  | `bulk_create_posts` | `posts:write` | Create a whole campaign of posts from one manifest |
173
224
  | `list_campaigns` | `posts:read` | Campaign tags in use, with post counts by status and platform |
@@ -268,11 +319,11 @@ Or, for a branded carousel post end-to-end:
268
319
  ```bash
269
320
  npm install
270
321
  npm run build # tsc → dist/
271
- npm test # validates server.json against the registry schema, boots the stdio server
322
+ npm test # builds, validates server.json and the plugin manifests, boots stdio, exercises hosted OAuth mode
272
323
  PLANPOST_API_KEY=pk_test_xxx node dist/index.js
273
324
  ```
274
325
 
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.
326
+ The server speaks MCP over stdio, or streamable HTTP with `--http`. It calls the PlanPost public API (`/api/public/v1`) with your credentials; nothing else is stored or sent anywhere. Changes are listed in [CHANGELOG.md](./CHANGELOG.md).
276
327
 
277
328
  ## Maintainers
278
329
 
@@ -283,7 +334,9 @@ and carries the matching `mcpName`. Glama and other directories import from the
283
334
  registry, so the second step is what makes a release discoverable.
284
335
 
285
336
  1. Bump `version` in `package.json` **and** in `server.json` (top level and
286
- `packages[0].version`). `npm test` fails if they disagree.
337
+ `packages[0].version`), `gemini-extension.json`, `.claude-plugin/plugin.json`
338
+ and `.cursor-plugin/plugin.json`, and add a `CHANGELOG.md` entry. `npm test`
339
+ fails if any of them disagree.
287
340
  2. Publish to npm first; the registry rejects a version npm does not have yet:
288
341
 
289
342
  ```bash
@@ -313,8 +366,8 @@ registry, so the second step is what makes a release discoverable.
313
366
 
314
367
  An expired token shows as "Invalid or expired Registry JWT token": run the login
315
368
  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).
369
+ A tagged GitHub release is what the Gemini CLI extension gallery and the
370
+ plugin marketplaces pick up, so tag `vX.Y.Z` after publishing.
318
371
 
319
372
  ## License
320
373
 
@@ -0,0 +1,3 @@
1
+ import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
2
+ export declare const ANNOTATED_TOOLS: string[];
3
+ export declare function annotationsFor(name: string): ToolAnnotations;
@@ -0,0 +1,66 @@
1
+ const TOOLS = {
2
+ list_posts: ["List posts", "read"],
3
+ get_post: ["Get a post", "read"],
4
+ // Can publish immediately, and a published post cannot be recalled.
5
+ create_post: ["Create, schedule or publish a post", "destroy", "world"],
6
+ preview_post: ["Preview a post per platform", "read"],
7
+ bulk_create_posts: ["Create several posts", "destroy", "world"],
8
+ list_campaigns: ["List campaigns", "read"],
9
+ delete_posts: ["Delete several posts", "destroy"],
10
+ list_media: ["List media library", "read"],
11
+ update_post: ["Edit a post", "destroy", "world"],
12
+ delete_post: ["Delete a post", "destroy"],
13
+ retry_failed: ["Retry failed platforms", "destroy", "world"],
14
+ get_delivery_report: ["Get delivery report", "read"],
15
+ publish_post: ["Publish a post now", "destroy", "world"],
16
+ list_connections: ["List connected social accounts", "read"],
17
+ list_account_groups: ["List account groups", "read"],
18
+ get_account_group: ["Get an account group", "read"],
19
+ create_account_group: ["Create an account group", "write"],
20
+ update_account_group: ["Edit an account group", "destroy"],
21
+ delete_account_group: ["Delete an account group", "destroy"],
22
+ add_connections_to_group: ["Add accounts to a group", "write"],
23
+ remove_connections_from_group: ["Remove accounts from a group", "destroy"],
24
+ get_connection_link: ["Get a link to connect an account", "read"],
25
+ submit_for_approval: ["Submit a post for approval", "write"],
26
+ list_pending_approvals: ["List pending approvals", "read"],
27
+ get_approval_status: ["Get approval status", "read"],
28
+ get_analytics: ["Get post analytics", "read"],
29
+ upload_media: ["Get a media upload URL", "write"],
30
+ repurpose_content: ["Repurpose content with AI", "write"],
31
+ list_video_templates: ["List video templates", "read"],
32
+ create_video: ["Render a video", "write"],
33
+ get_video_status: ["Get video render status", "read"],
34
+ generate_carousel: ["Draft carousel slides", "write"],
35
+ list_carousel_styles: ["List carousel styles", "read"],
36
+ render_carousel: ["Render a carousel", "write"],
37
+ get_carousel_render: ["Get carousel render status", "read"],
38
+ list_brand_kits: ["List brand kits", "read"],
39
+ list_content_sources: ["List content sources", "read"],
40
+ add_content_source: ["Add a content source", "write", "world"],
41
+ delete_content_source: ["Delete a content source", "destroy"],
42
+ fetch_source_content: ["Sync a content source", "write", "world"],
43
+ list_imported_content: ["List imported content", "read"],
44
+ get_revenue_overview: ["Get revenue overview", "read"],
45
+ generate_image: ["Generate an image", "write"],
46
+ get_brand_voices: ["List brand voices", "read"],
47
+ get_brand_voice_prompt: ["Get a brand voice prompt", "read"],
48
+ get_best_times: ["Get best times to post", "read"],
49
+ suggest_schedule: ["Suggest a schedule", "read"],
50
+ get_posting_schedule: ["Get posting schedule", "read"],
51
+ set_posting_schedule: ["Replace posting schedule", "destroy"],
52
+ };
53
+ export const ANNOTATED_TOOLS = Object.keys(TOOLS);
54
+ export function annotationsFor(name) {
55
+ const entry = TOOLS[name];
56
+ if (!entry)
57
+ throw new Error(`Tool "${name}" has no entry in annotations.ts`);
58
+ const [title, kind, world] = entry;
59
+ return {
60
+ title,
61
+ readOnlyHint: kind === "read",
62
+ destructiveHint: kind === "destroy",
63
+ idempotentHint: kind === "read",
64
+ openWorldHint: world === "world",
65
+ };
66
+ }
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,12 +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
99
  // `needs_approval` is a first-class status, not an internal one: a post
78
100
  // parked for human sign-off is exactly what an agent needs to be able to
79
101
  // 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)"),
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)"),
81
103
  platform: z.string().optional().describe("Filter by platform (instagram, twitter, linkedin, etc.)"),
82
104
  campaign: z.string().optional().describe("Filter by campaign tag, e.g. 'garmin-cirqa-2026' (matches utm.campaign; slugified automatically)"),
83
105
  label: z.string().optional().describe("Filter by internal label (case-insensitive substring), e.g. 'Garmin CIRQA'"),
@@ -119,7 +141,7 @@ function registerTools(server) {
119
141
  content: z.string().describe("Post text content"),
120
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."),
121
143
  platformContent: z.record(z.string(), z.string()).optional().describe("Per-platform content overrides, e.g. { twitter: 'shorter version', linkedin: 'longer version' }"),
122
- 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."),
123
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."),
124
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."),
125
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."),
@@ -184,7 +206,7 @@ function registerTools(server) {
184
206
  : post.scheduledFor
185
207
  ? `Scheduled for ${post.scheduledFor}`
186
208
  : "Saved as draft";
187
- 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)}` }] };
188
210
  });
189
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.", {
190
212
  id: z.string().describe("Post ID to preview"),
@@ -209,7 +231,10 @@ function registerTools(server) {
209
231
  pl.content.length > 400 ? `${pl.content.slice(0, 400)}…` : pl.content,
210
232
  ].filter(Boolean).join("\n");
211
233
  });
212
- 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}`;
213
238
  return {
214
239
  content: [{
215
240
  type: "text",
@@ -227,7 +252,9 @@ function registerTools(server) {
227
252
  const data = await api("/posts/bulk", { method: "POST", body: JSON.stringify(body) });
228
253
  const d = data.data;
229
254
  const lines = (d.results ?? []).map((r) => r.ok
230
- ? ` #${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
+ : ""}`
231
258
  : ` #${r.index} FAILED ${r.error}`);
232
259
  return {
233
260
  content: [{
@@ -290,7 +317,7 @@ function registerTools(server) {
290
317
  content: z.string().optional().describe("New post text content"),
291
318
  connectionIds: z.array(z.string()).optional().describe("New connection IDs"),
292
319
  platformContent: z.record(z.string(), z.string()).optional().describe("Per-platform content overrides"),
293
- 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."),
294
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."),
295
322
  threadSegments: z.array(z.string()).optional().describe("Ordered tweet segments for a thread (postType='thread'). 2–25 segments, each 1–280 characters."),
296
323
  label: z.string().nullable().optional().describe("Internal working name shown in the dashboard. Pass null to clear. Never published."),
@@ -353,7 +380,7 @@ function registerTools(server) {
353
380
  if (timezone !== undefined)
354
381
  body.timezone = timezone;
355
382
  const data = await api(`/posts/${id}`, { method: "PUT", body: JSON.stringify(body) });
356
- 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)}` }] };
357
384
  });
358
385
  server.tool("delete_post", "Delete a post.", {
359
386
  id: z.string().describe("Post ID to delete"),
@@ -361,6 +388,84 @@ function registerTools(server) {
361
388
  await api(`/posts/${id}`, { method: "DELETE" });
362
389
  return { content: [{ type: "text", text: `Post ${id} deleted.` }] };
363
390
  });
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
+ });
364
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.", {
365
470
  id: z.string().describe("Post ID to publish now"),
366
471
  }, async ({ id }) => {
@@ -370,8 +475,21 @@ function registerTools(server) {
370
475
  server.tool("list_connections", "List all connected social media accounts (Instagram, Twitter, LinkedIn, etc.).", {}, async () => {
371
476
  const data = await api("/connections");
372
477
  const connections = data.data;
373
- const summary = connections.map((c) => `${c.provider} — ${c.metadata?.accountName || c.providerUsername} (ID: ${c.id})`).join("\n");
374
- 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)}` }] };
375
493
  });
376
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 () => {
377
495
  const data = await api("/account-groups");
@@ -837,23 +955,30 @@ function readBody(req) {
837
955
  * Remote/streamable-HTTP mode: `planpost-mcp --http [--port 3020]`.
838
956
  *
839
957
  * Stateless — every POST /mcp builds a fresh server + transport, and the
840
- * caller's `Authorization: Bearer pk_...` key is scoped to that request via
841
- * AsyncLocalStorage. That makes one hosted instance safely multi-tenant:
842
- * whoever holds a PlanPost API key can point any HTTP-capable MCP client
843
- * (e.g. `claude mcp add --transport http planpost https://host/mcp --header
844
- * "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.
845
968
  */
846
969
  async function startHttp(port, host) {
970
+ const oauth = loadOAuthSettings(process.env, BASE_URL);
971
+ const verifier = oauth ? new AccessTokenVerifier(oauth) : null;
847
972
  const httpServer = createHttpServer(async (req, res) => {
848
973
  // An API host must never rank: Google indexed the bare /mcp 405 JSON as a
849
974
  // page under the brand. The header, not robots.txt, is the fix on purpose:
850
975
  // a crawler has to be able to fetch the URL to see the noindex, so
851
976
  // robots.txt below allows crawling and this header does the de-indexing.
852
977
  res.setHeader("X-Robots-Tag", "noindex, nofollow");
853
- const fail = (status, message) => {
978
+ const fail = (status, message, headers = {}) => {
854
979
  // charset is load-bearing: without it, any non-ASCII byte in a message
855
980
  // renders as mojibake in browsers that assume latin-1 for JSON.
856
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
981
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
857
982
  res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
858
983
  };
859
984
  try {
@@ -876,6 +1001,28 @@ async function startHttp(port, host) {
876
1001
  res.end();
877
1002
  return;
878
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
+ }
879
1026
  if (url.pathname !== "/mcp")
880
1027
  return fail(404, "Not found. The MCP endpoint is /mcp");
881
1028
  if (req.method !== "POST") {
@@ -883,10 +1030,44 @@ async function startHttp(port, host) {
883
1030
  return fail(405, "Method not allowed. This stateless transport accepts POST only; setup guide at https://planpo.st/mcp");
884
1031
  }
885
1032
  const auth = req.headers.authorization || "";
886
- const key = auth.startsWith("Bearer ") ? auth.slice(7) : "";
887
- 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) {
888
1039
  return fail(401, "Provide your PlanPost API key: Authorization: Bearer pk_...");
889
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
+ }
890
1071
  const body = await readBody(req);
891
1072
  const server = buildServer();
892
1073
  const transport = new StreamableHTTPServerTransport({
@@ -897,7 +1078,7 @@ async function startHttp(port, host) {
897
1078
  server.close();
898
1079
  });
899
1080
  await server.connect(transport);
900
- await requestApiKey.run(key, () => transport.handleRequest(req, res, body));
1081
+ await requestCredential.run(credential, () => transport.handleRequest(req, res, body));
901
1082
  }
902
1083
  catch (err) {
903
1084
  console.error("[planpost-mcp] request error:", err);
@@ -906,7 +1087,8 @@ async function startHttp(port, host) {
906
1087
  }
907
1088
  });
908
1089
  httpServer.listen(port, host, () => {
909
- 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)"));
910
1092
  });
911
1093
  }
912
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
+ }
package/dist/oauth.js ADDED
@@ -0,0 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { checkResourceAllowed } from "@modelcontextprotocol/sdk/shared/auth-utils.js";
3
+ export function loadOAuthSettings(env, apiUrl) {
4
+ const publicUrl = env.PLANPOST_MCP_PUBLIC_URL;
5
+ const serviceSecret = env.PLANPOST_MCP_SERVICE_SECRET;
6
+ if (!publicUrl || !serviceSecret)
7
+ return null;
8
+ if (serviceSecret.length < 32) {
9
+ throw new Error("PLANPOST_MCP_SERVICE_SECRET must be at least 32 characters");
10
+ }
11
+ return {
12
+ resourceUrl: new URL(publicUrl),
13
+ issuer: (env.PLANPOST_OAUTH_ISSUER || apiUrl).replace(/\/+$/, ""),
14
+ apiUrl: apiUrl.replace(/\/+$/, ""),
15
+ serviceSecret,
16
+ };
17
+ }
18
+ export function upstreamHeaders(credential) {
19
+ return credential.kind === "key"
20
+ ? { Authorization: `Bearer ${credential.key}` }
21
+ : {
22
+ "X-Planpost-Mcp-Secret": credential.serviceSecret,
23
+ "X-Planpost-OAuth-Grant": credential.grantId,
24
+ };
25
+ }
26
+ /** RFC 9728 §3.1: the well-known path carries the resource's own path as a suffix. */
27
+ export function resourceMetadataUrl(settings) {
28
+ const { resourceUrl } = settings;
29
+ const suffix = resourceUrl.pathname === "/" ? "" : resourceUrl.pathname;
30
+ return new URL(`/.well-known/oauth-protected-resource${suffix}`, resourceUrl).href;
31
+ }
32
+ export function protectedResourceMetadata(settings) {
33
+ return {
34
+ resource: settings.resourceUrl.href,
35
+ authorization_servers: [settings.issuer],
36
+ bearer_methods_supported: ["header"],
37
+ resource_name: "PlanPost",
38
+ resource_documentation: "https://planpo.st/docs/agents",
39
+ };
40
+ }
41
+ /**
42
+ * The `WWW-Authenticate` value for a 401. No `scope`: the client then asks
43
+ * for the authorization server's `scopes_supported`, and the user narrows
44
+ * nothing by accident.
45
+ */
46
+ export function bearerChallenge(settings, invalidTokenReason) {
47
+ const parts = invalidTokenReason
48
+ ? [`error="invalid_token"`, `error_description="${invalidTokenReason.replace(/["\\]/g, "")}"`]
49
+ : [];
50
+ parts.push(`resource_metadata="${resourceMetadataUrl(settings)}"`);
51
+ return `Bearer ${parts.join(", ")}`;
52
+ }
53
+ /** The token is not valid here: answer 401 with a challenge. */
54
+ export class InvalidTokenError extends Error {
55
+ }
56
+ /** The authorization server could not be asked: answer 503, not 401. */
57
+ export class IntrospectionUnavailableError extends Error {
58
+ }
59
+ const CACHE_TTL_MS = 30_000;
60
+ const CACHE_MAX = 2_000;
61
+ export class AccessTokenVerifier {
62
+ settings;
63
+ fetchImpl;
64
+ cache = new Map();
65
+ constructor(settings, fetchImpl = fetch) {
66
+ this.settings = settings;
67
+ this.fetchImpl = fetchImpl;
68
+ }
69
+ /**
70
+ * Introspects the token and checks it was minted for this server. Results
71
+ * are cached briefly; revocation still bites at once, because the API looks
72
+ * the grant up again on every tool call.
73
+ */
74
+ async verify(token) {
75
+ const cacheKey = createHash("sha256").update(token).digest("hex");
76
+ const now = Date.now();
77
+ const hit = this.cache.get(cacheKey);
78
+ if (hit && hit.until > now && (hit.info.expiresAt ?? 0) * 1000 > now)
79
+ return hit.info;
80
+ let response;
81
+ try {
82
+ response = await this.fetchImpl(`${this.settings.apiUrl}/oauth/introspect`, {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/x-www-form-urlencoded",
86
+ Authorization: `Bearer ${this.settings.serviceSecret}`,
87
+ },
88
+ body: new URLSearchParams({ token }).toString(),
89
+ });
90
+ }
91
+ catch (err) {
92
+ throw new IntrospectionUnavailableError(`introspection request failed: ${String(err)}`);
93
+ }
94
+ if (!response.ok) {
95
+ throw new IntrospectionUnavailableError(`introspection returned HTTP ${response.status}`);
96
+ }
97
+ const data = (await response.json());
98
+ if (!data.active || !data.grant_id || typeof data.exp !== "number") {
99
+ this.cache.delete(cacheKey);
100
+ throw new InvalidTokenError("The access token is invalid, expired or revoked");
101
+ }
102
+ if (!data.aud ||
103
+ !checkResourceAllowed({
104
+ requestedResource: data.aud,
105
+ configuredResource: this.settings.resourceUrl,
106
+ })) {
107
+ throw new InvalidTokenError("The access token was not issued for this server");
108
+ }
109
+ const info = {
110
+ token,
111
+ clientId: data.client_id ?? "",
112
+ scopes: data.scope ? data.scope.split(" ") : [],
113
+ expiresAt: data.exp,
114
+ resource: this.settings.resourceUrl,
115
+ extra: { grantId: data.grant_id },
116
+ };
117
+ if (this.cache.size >= CACHE_MAX)
118
+ this.cache.clear();
119
+ this.cache.set(cacheKey, { info, until: now + CACHE_TTL_MS });
120
+ return info;
121
+ }
122
+ }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "planpost-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
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
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.",
6
+ "description": "PlanPost MCP server, the agentic social scheduler with revenue attribution: schedule and publish posts to Instagram, TikTok, LinkedIn, X, Facebook, Threads and YouTube from Claude, Cursor, Codex or any MCP client, render video and carousels, and read revenue per post.",
7
7
  "license": "MIT",
8
8
  "type": "module",
9
9
  "bin": {
@@ -11,7 +11,8 @@
11
11
  },
12
12
  "files": [
13
13
  "dist",
14
- "skills"
14
+ "skills",
15
+ "CHANGELOG.md"
15
16
  ],
16
17
  "publishConfig": {
17
18
  "access": "public"
@@ -22,11 +23,10 @@
22
23
  "homepage": "https://planpo.st/mcp",
23
24
  "repository": {
24
25
  "type": "git",
25
- "url": "git+https://github.com/lukBB/social-poster-v2.git",
26
- "directory": "packages/mcp-server"
26
+ "url": "git+https://github.com/lukBB/planpost-mcp.git"
27
27
  },
28
28
  "bugs": {
29
- "url": "https://planpo.st/support"
29
+ "url": "https://github.com/lukBB/planpost-mcp/issues"
30
30
  },
31
31
  "keywords": [
32
32
  "mcp",
@@ -55,6 +55,7 @@
55
55
  "dev": "tsc --watch",
56
56
  "start": "node dist/index.js",
57
57
  "prepublishOnly": "npm run build",
58
+ "pretest": "npm run build",
58
59
  "test": "node --test test/*.test.mjs"
59
60
  },
60
61
  "dependencies": {
@@ -62,9 +63,9 @@
62
63
  "zod": "^4.3.6"
63
64
  },
64
65
  "devDependencies": {
65
- "typescript": "^5.7.0",
66
66
  "@types/node": "^22.0.0",
67
67
  "ajv": "^8.17.1",
68
- "ajv-formats": "^3.0.1"
68
+ "ajv-formats": "^3.0.1",
69
+ "typescript": "^5.7.0"
69
70
  }
70
71
  }
@@ -0,0 +1,114 @@
1
+ ---
2
+ name: planpost
3
+ description: Draft, schedule and publish social media posts through PlanPost, the agentic social scheduler with revenue attribution, and report what they earned. Use when the user wants to post, schedule, queue, repurpose or bulk-plan content for Instagram, TikTok, LinkedIn, X (Twitter), Facebook, Threads or YouTube, render a carousel or video for a post, check a post's status or a failed publish, or ask which posts drove clicks, signups or revenue. Requires the PlanPost MCP server (https://mcp.planpo.st/mcp).
4
+ license: MIT
5
+ metadata:
6
+ homepage: https://planpo.st/mcp
7
+ ---
8
+
9
+ # PlanPost
10
+
11
+ PlanPost schedules and publishes to seven social networks and attributes
12
+ revenue back to individual posts. This skill is the operating manual for its
13
+ MCP tools. Tool names below are the ones the server exposes; your client may
14
+ prefix them (for example `mcp__planpost__create_post`).
15
+
16
+ ## Before anything else
17
+
18
+ If no PlanPost tools are available, the server is not connected. Tell the user
19
+ to add `https://mcp.planpo.st/mcp` as a remote MCP server (connector) in their
20
+ agent and sign in when the browser opens. Details per client:
21
+ https://planpo.st/docs/agents. Do not ask the user to paste an API key into
22
+ the chat.
23
+
24
+ ## The rules that prevent bad posts
25
+
26
+ 1. **Resolve targets, never guess them.** Call `list_account_groups` (named
27
+ sets such as "Agency clients") or `list_connections` and use the IDs they
28
+ return. If the user names a group or handle you cannot find, show what
29
+ exists and ask.
30
+ 2. **Draft unless told otherwise.** `create_post` with no `scheduledFor` saves
31
+ a draft. Schedule only when the user gave a time, and publish immediately
32
+ (`publish_post`) only when they said "now" in so many words.
33
+ 3. **Say the time back.** Pass `scheduledFor` as ISO 8601 plus the user's
34
+ `timezone`, and repeat the local time in your answer. If they gave no time
35
+ and want one, `suggest_schedule` or `get_best_times` picks it.
36
+ 4. **Approval is not a failure.** A post that comes back `needs_approval` is
37
+ waiting for the account owner in PlanPost. Report that and stop; do not
38
+ retry or re-create it. `get_approval_status` tells you when it clears.
39
+ 5. **Read warnings.** Responses carry `warnings[]` (what will be truncated or
40
+ dropped at publish time) and sometimes `preflight.errors[]` (the post was
41
+ saved as a draft because it would fail). Fix what they name, then
42
+ `update_post`.
43
+ 6. **X (Twitter) takes no links.** No URL, bare domain or t.co in the post,
44
+ any thread segment, or the first comment. Put a link-free variant in
45
+ `platformContent.twitter`.
46
+ 7. **TikTok needs a visibility choice.** A scheduled or published TikTok post
47
+ must carry `platformSettings.tiktok.privacyLevel`. Ask the user if they
48
+ have not said.
49
+
50
+ ## Common jobs
51
+
52
+ **Post one thing to several networks**
53
+
54
+ 1. `list_account_groups` → pick the group, or `list_connections` → pick IDs.
55
+ 2. `preview_post` with the text to see per-network length and media problems.
56
+ 3. `create_post` with `content`, `connectionIds`, optional
57
+ `platformContent` overrides and `scheduledFor`.
58
+ 4. Give the user the status and the `Open in PlanPost` link from the response.
59
+
60
+ **Plan a week or a campaign**
61
+
62
+ Use `bulk_create_posts` with one manifest instead of looping `create_post`.
63
+ Give every post the same `campaign` so `list_posts` and `list_campaigns` can
64
+ find them again. To fill the user's standing queue instead of picking times,
65
+ read `get_posting_schedule` first.
66
+
67
+ **Attach media**
68
+
69
+ Reuse before uploading: `list_media` shows what is already in the library.
70
+ For a new file, `upload_media` returns a presigned URL; upload to it, then
71
+ pass the returned asset in `media`.
72
+
73
+ **Carousel**
74
+
75
+ `generate_carousel` (slide text) → `list_carousel_styles` or
76
+ `list_brand_kits` → `render_carousel` → poll `get_carousel_render` until
77
+ `completed` → `create_post` with the returned `assets` as `media`.
78
+
79
+ **Video**
80
+
81
+ `list_video_templates` → `create_video` → poll `get_video_status` →
82
+ `create_post` with `postType: "reel"` or `"short"`.
83
+
84
+ **Turn an article into posts**
85
+
86
+ `list_content_sources` → `fetch_source_content` → `list_imported_content` →
87
+ write per-network variants (use `get_brand_voice_prompt` when a brand voice
88
+ exists) → `create_post` as drafts.
89
+
90
+ **A post failed**
91
+
92
+ `get_post` → read `platformResults[]`: `errorClass`, `error`, `fix`.
93
+ `retry_failed` re-queues only transient failures and never re-posts a network
94
+ that already went out. Failures that need the user (reconnect an account,
95
+ choose TikTok visibility) come back with the fix to relay.
96
+
97
+ **What worked?**
98
+
99
+ `get_analytics` for reach and engagement, `get_revenue_overview` for revenue,
100
+ signups and clicks attributed to posts. Lead with the posts that earned
101
+ money, not the ones with the most likes.
102
+
103
+ ## Limits to respect
104
+
105
+ - Calls count against the user's monthly API allowance; prefer one
106
+ `bulk_create_posts` over many `create_post` calls, and do not poll render
107
+ status faster than every few seconds.
108
+ - A tool that answers with a missing-permission error means the user did not
109
+ grant that scope. Say which capability is missing; do not look for a
110
+ workaround.
111
+ - Image, video and carousel generation spend the user's AI credits or render
112
+ quota. Confirm before generating more than a couple.
113
+ - Connecting a new social account needs a human in a browser:
114
+ `get_connection_link` returns the link to send them.