planpost-mcp 0.3.0 → 0.4.1

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,70 @@
1
+ # Changelog
2
+
3
+ All notable changes to `planpost-mcp`. Versions follow [semver](https://semver.org).
4
+
5
+ ## 0.4.1 — 2026-11-14
6
+
7
+ ### Changed
8
+ - Agents that sign in with OAuth see 34 tools: the scheduling, delivery,
9
+ analytics, revenue and approval tools. The video, carousel, brand kit,
10
+ content source and repurposing tools are left out for them, because those
11
+ features are parked in the PlanPost app; an API key still gets all 49.
12
+ - `create_post` no longer points agents at the carousel tools; several
13
+ attached images publish as a carousel where the network supports one.
14
+ - Descriptions in the README, `server.json`, the plugin manifests, GEMINI.md
15
+ and the skill describe the product as it is now: an agentic scheduler, with
16
+ approval as a choice the user makes (off by default).
17
+ - `generate_image` is among the tools left out for OAuth sign-ins: it calls a
18
+ generative image model, which the Claude directory does not allow in a
19
+ listed server.
20
+ - `.claude-plugin/marketplace.json` has a marketplace description.
21
+
22
+ ### Removed
23
+ - The `send-to-plan-post` skill. It described one account's own groups, not
24
+ the product. `skills/planpost` is the skill to install.
25
+
26
+ ## 0.4.0 — 2026-11-09
27
+
28
+ ### Added
29
+ - **OAuth sign-in for the hosted server.** With `--http`, the server is now an
30
+ OAuth 2.1 resource server per the MCP authorization spec (revision
31
+ 2026-07-28): protected-resource metadata at
32
+ `/.well-known/oauth-protected-resource`, a `401` +
33
+ `WWW-Authenticate: Bearer resource_metadata="…"` challenge for
34
+ unauthenticated requests, access-token validation by introspection with an
35
+ audience check, and no token passthrough. Clients that support remote MCP
36
+ authorization (Claude, ChatGPT, Cursor, Gemini CLI, VS Code) can connect to
37
+ `https://mcp.planpo.st/mcp` with no key. Enabled by
38
+ `PLANPOST_MCP_PUBLIC_URL` + `PLANPOST_MCP_SERVICE_SECRET`; without them the
39
+ server behaves exactly as before.
40
+ - **Tool annotations** on all 49 tools: `title`, `readOnlyHint`,
41
+ `destructiveHint`, `idempotentHint`, `openWorldHint`.
42
+ - **Agent bundles** in the repository: `skills/planpost/SKILL.md`
43
+ (`npx skills add`), `gemini-extension.json` + `GEMINI.md` (Gemini CLI
44
+ extension), `.claude-plugin/` (Claude Code plugin and marketplace) and
45
+ `.cursor-plugin/` + `mcp.json` (Cursor plugin).
46
+ - Tests for hosted OAuth mode and for manifest consistency; `npm test` now
47
+ builds first.
48
+
49
+ ### Changed
50
+ - `server.json`: the hosted remote's `Authorization` header is optional
51
+ (omit it to sign in with OAuth).
52
+ - API-key authentication (`Authorization: Bearer pk_…` over HTTP,
53
+ `PLANPOST_API_KEY` over stdio) is unchanged.
54
+
55
+ ## 0.3.0 — 2026-11-08
56
+
57
+ - Listed on the official MCP Registry as `io.github.lukBB/planpost`
58
+ (`server.json`, `mcpName`).
59
+ - Hosted-first README: install snippets for Claude Code, Claude Desktop,
60
+ Cursor, Codex CLI and Gemini CLI.
61
+ - `retry_failed` and `get_delivery_report` tools; publish failures carry an
62
+ error class and a suggested fix.
63
+ - `npm test`: registry schema validation and a stdio boot test.
64
+
65
+ ## 0.2.x
66
+
67
+ - Streamable HTTP mode (`--http`) with per-request API keys, powering
68
+ `https://mcp.planpo.st/mcp`.
69
+ - Account groups, approvals, smart scheduling, carousels, video rendering,
70
+ content sources, brand voice, revenue overview.
package/README.md CHANGED
@@ -1,21 +1,56 @@
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 your AI agent can run. It lets Claude, ChatGPT, Cursor, Gemini CLI and other AI agents draft, schedule and publish posts to Instagram, Facebook, LinkedIn, X, Threads, TikTok and YouTube, see what failed and why, and read analytics and revenue from 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
+ (they go out at the time the agent sets unless you turn that on). No key to
14
+ create or paste.
15
+
16
+ ```bash
17
+ claude mcp add --transport http planpost https://mcp.planpo.st/mcp
18
+ ```
19
+
20
+ ```json
21
+ { "mcpServers": { "planpost": { "url": "https://mcp.planpo.st/mcp" } } }
22
+ ```
23
+
24
+ Connected agents are listed on the **Agent** page under **Connected apps**,
25
+ where you can turn approval on or off for each one, or remove it, at any time. They use the same permissions and the
26
+ same monthly call allowance as API keys.
27
+
28
+ The repository also ships ready-made bundles: a Claude Code plugin
29
+ (`.claude-plugin/`), a Cursor plugin (`.cursor-plugin/`), a Gemini CLI
30
+ extension (`gemini-extension.json`) and an agent skill:
31
+
32
+ ```bash
33
+ npx skills add lukBB/planpost-mcp --skill planpost
34
+ gemini extensions install https://github.com/lukBB/planpost-mcp
35
+ claude plugin marketplace add lukBB/planpost-mcp && claude plugin install planpost@planpost-plugins
36
+ ```
37
+
38
+ The rest of this section is the API-key route: use it for clients without
39
+ OAuth support, for CI, or when you want a key limited to a few permissions.
40
+
7
41
  ### 1. Get an API key
8
42
 
9
- In the PlanPost dashboard go to **Settings → API Keys → Create Key** and select the permissions you need (see the table below). The key (`pk_live_…`) is shown **once** — copy it.
43
+ In the PlanPost dashboard open the **Agent** page and choose **Create key** and select the permissions you need (see the table below). The key (`pk_live_…`) is shown **once** — copy it.
10
44
 
11
- > API/MCP access is included in every paid plan — Creator: 1,000 calls/month, Pro: 10,000 calls/month. Keys are scoped: a key with only `posts:read` cannot publish.
45
+ > API/MCP access is included on every plan: Free 200 calls a month, Creator 1,000, Pro 10,000. Keys are scoped: a key with only `posts:read` cannot publish.
12
46
 
13
47
  ### 2. Add the server to your agent
14
48
 
15
49
  Two ways to connect. **Hosted** is the default: point your client at
16
50
  `https://mcp.planpo.st/mcp`, nothing to install, your key travels as a header.
17
51
  **Local** runs the same server on your machine with `npx`, so the key never
18
- leaves it. Both expose the same 47 tools.
52
+ leaves it. Both expose the same 49 tools to an API key; an agent that signs in
53
+ with OAuth gets 34 of them (see the note under Tools).
19
54
 
20
55
  Replace `pk_live_xxx` with your key throughout.
21
56
 
@@ -131,19 +166,35 @@ Behind a reverse proxy, bind to loopback so the port isn't reachable directly:
131
166
  HOST=127.0.0.1 npx -y planpost-mcp --http --port 3020
132
167
  ```
133
168
 
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.
169
+ The transport is stateless (streamable HTTP); credentials are read from every
170
+ request and scoped to it, so one hosted instance serves many users safely. Put
171
+ it behind HTTPS in production. `--host` / `HOST` default to `0.0.0.0`, so
172
+ containers and LAN use work without extra flags.
173
+
174
+ A self-hosted endpoint accepts API keys (`Authorization: Bearer pk_...`) out of
175
+ the box. OAuth sign-in is switched on by three variables, and needs a PlanPost
176
+ API that runs the authorization server (`/oauth/*`):
177
+
178
+ | Variable | Description |
179
+ |---|---|
180
+ | `PLANPOST_MCP_PUBLIC_URL` | Public URL of this endpoint, e.g. `https://mcp.example.com/mcp`. Tokens are issued for exactly this URL. |
181
+ | `PLANPOST_MCP_SERVICE_SECRET` | 32+ random characters shared with the API (`MCP_SERVICE_SECRET` there). |
182
+ | `PLANPOST_OAUTH_ISSUER` | Authorization server origin. Defaults to `PLANPOST_API_URL`. |
183
+ | `OPENAI_APPS_CHALLENGE` | Optional. Token served at `/.well-known/openai-apps-challenge` for ChatGPT directory domain verification. |
184
+
185
+ With those set the server follows the MCP authorization spec: it serves
186
+ `/.well-known/oauth-protected-resource`, answers unauthenticated requests with
187
+ `401` and a `WWW-Authenticate: Bearer resource_metadata="…"` challenge,
188
+ validates access tokens by introspection, checks they were issued for this
189
+ endpoint, and never forwards them upstream.
139
190
 
140
191
  ### 3. Talk to it
141
192
 
142
193
  > "Schedule this post to my Polish Pages group for tomorrow 9:00: …"
143
194
  >
144
- > "Make a quote video from this testimonial and publish it as a reel."
195
+ > "Here is my new blog post. Write one version per network and spread them across next week."
145
196
  >
146
- > "Pull the latest posts from my WordPress blog and draft a LinkedIn carousel from the newest one."
197
+ > "What failed to publish this week, and what do I need to fix?"
147
198
  >
148
199
  > "How much revenue did my posts drive last 30 days?"
149
200
 
@@ -151,12 +202,12 @@ extra flags.
151
202
 
152
203
  | Variable | Required | Description |
153
204
  |---|---|---|
154
- | `PLANPOST_API_KEY` | ✅ | API key from Settings → API Keys (`pk_live_…`) |
205
+ | `PLANPOST_API_KEY` | stdio only | API key from the Agent page in PlanPost (`pk_live_…`). Not needed with `--http`, where each request brings its own credentials. |
155
206
  | `PLANPOST_API_URL` | — | API base URL (default `https://api.planpo.st`) |
156
207
 
157
208
  ## Tools
158
209
 
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.
210
+ 49 tools, grouped below. An agent that signs in with OAuth (the hosted server with no key, which is how Claude, ChatGPT and Cursor connect) is offered 34: the AI image tool and the video, carousel, brand kit, content source and repurposing tools are left out, because those features are parked in the PlanPost app. An API key gets all 49. 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
211
 
161
212
  ### Posts & publishing
162
213
 
@@ -168,6 +219,8 @@ extra flags.
168
219
  | `update_post` | `posts:write` | Update a draft/scheduled post |
169
220
  | `delete_post` | `posts:write` | Delete a post |
170
221
  | `publish_post` | `posts:write` | Publish immediately |
222
+ | `retry_failed` | `posts:write` | Re-queue the platforms that failed for a transient reason; never re-posts one that went out |
223
+ | `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
224
  | `preview_post` | `posts:read` | Show exactly what each platform will receive (text after overrides, attachments, warnings) |
172
225
  | `bulk_create_posts` | `posts:write` | Create a whole campaign of posts from one manifest |
173
226
  | `list_campaigns` | `posts:read` | Campaign tags in use, with post counts by status and platform |
@@ -192,6 +245,8 @@ extra flags.
192
245
 
193
246
  Connecting a new social account requires OAuth in a browser: `get_connection_link` hands the user the link, and the agent can target the account once it is connected.
194
247
 
248
+ X needs a paid plan. On the free plan `get_connection_link` answers with an upgrade message for `twitter`, `list_connections` marks an X account that is already connected as not on the current plan, and a post that targets X is saved as a draft with the `x_requires_paid_plan` error.
249
+
195
250
  ### Approvals
196
251
 
197
252
  | Tool | Required permission | Description |
@@ -268,11 +323,11 @@ Or, for a branded carousel post end-to-end:
268
323
  ```bash
269
324
  npm install
270
325
  npm run build # tsc → dist/
271
- npm test # validates server.json against the registry schema, boots the stdio server
326
+ npm test # builds, validates server.json and the plugin manifests, boots stdio, exercises hosted OAuth mode
272
327
  PLANPOST_API_KEY=pk_test_xxx node dist/index.js
273
328
  ```
274
329
 
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.
330
+ 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
331
 
277
332
  ## Maintainers
278
333
 
@@ -283,7 +338,9 @@ and carries the matching `mcpName`. Glama and other directories import from the
283
338
  registry, so the second step is what makes a release discoverable.
284
339
 
285
340
  1. Bump `version` in `package.json` **and** in `server.json` (top level and
286
- `packages[0].version`). `npm test` fails if they disagree.
341
+ `packages[0].version`), `gemini-extension.json`, `.claude-plugin/plugin.json`
342
+ and `.cursor-plugin/plugin.json`, and add a `CHANGELOG.md` entry. `npm test`
343
+ fails if any of them disagree.
287
344
  2. Publish to npm first; the registry rejects a version npm does not have yet:
288
345
 
289
346
  ```bash
@@ -313,8 +370,8 @@ registry, so the second step is what makes a release discoverable.
313
370
 
314
371
  An expired token shows as "Invalid or expired Registry JWT token": run the login
315
372
  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).
373
+ A tagged GitHub release is what the Gemini CLI extension gallery and the
374
+ plugin marketplaces pick up, so tag `vX.Y.Z` after publishing.
318
375
 
319
376
  ## License
320
377
 
@@ -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")
@@ -66,18 +83,64 @@ function formatLinks(links) {
66
83
  // ─── MCP Server ───
67
84
  // Tool registration is a function so HTTP mode can build a fresh server per
68
85
  // request (stateless transport); stdio builds one at startup.
69
- function buildServer() {
86
+ function buildServer(omit = NO_TOOLS) {
70
87
  const server = new McpServer(SERVER_INFO);
71
- registerTools(server);
88
+ registerTools(server, omit);
72
89
  return server;
73
90
  }
91
+ const NO_TOOLS = new Set();
92
+ /**
93
+ * Tools left out for agents that signed in with OAuth, which is how every
94
+ * directory-listed client (Claude, ChatGPT, Cursor) connects.
95
+ *
96
+ * - generate_image calls a generative image model. The Claude directory's
97
+ * compliance step requires the statement "My MCP server does not use AI
98
+ * models to generate images, video, or audio", and a tool that is merely
99
+ * discouraged would make that statement false.
100
+ * - Video and carousel templates, brand kits, content sources and repurposing
101
+ * belong to features parked in the app since 2026-11-11 (PARKED_NAV in
102
+ * apps/web/lib/config/nav.tsx). A signed-in agent is offered what the
103
+ * product sells: scheduling, delivery, results and approvals.
104
+ *
105
+ * API-key connections, which the user sets up by hand, keep the full set.
106
+ * To bring a parked feature back, remove its tools here and its nav entry.
107
+ */
108
+ const OMITTED_FOR_SIGNED_IN_AGENTS = new Set([
109
+ "generate_image",
110
+ // Video templates
111
+ "create_video",
112
+ "get_video_status",
113
+ "list_video_templates",
114
+ // Carousel templates and brand kits
115
+ "generate_carousel",
116
+ "render_carousel",
117
+ "get_carousel_render",
118
+ "list_carousel_styles",
119
+ "list_brand_kits",
120
+ // Content sources and repurposing
121
+ "add_content_source",
122
+ "delete_content_source",
123
+ "fetch_source_content",
124
+ "list_content_sources",
125
+ "list_imported_content",
126
+ "repurpose_content",
127
+ ]);
74
128
  // ─── Tools ───
75
- function registerTools(server) {
129
+ function registerTools(target, omit = NO_TOOLS) {
130
+ // Every tool below is registered through this shim so its annotations
131
+ // (title, read-only / destructive hints) come from annotations.ts.
132
+ const server = {
133
+ tool: ((name, description, schema, handler) => {
134
+ if (omit.has(name))
135
+ return undefined;
136
+ return target.tool(name, description, schema, annotationsFor(name), handler);
137
+ }),
138
+ };
76
139
  server.tool("list_posts", "List your scheduled and published social media posts. Filter by status, platform, campaign, label, post type, or free text.", {
77
140
  // `needs_approval` is a first-class status, not an internal one: a post
78
141
  // parked for human sign-off is exactly what an agent needs to be able to
79
142
  // 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)"),
143
+ 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
144
  platform: z.string().optional().describe("Filter by platform (instagram, twitter, linkedin, etc.)"),
82
145
  campaign: z.string().optional().describe("Filter by campaign tag, e.g. 'garmin-cirqa-2026' (matches utm.campaign; slugified automatically)"),
83
146
  label: z.string().optional().describe("Filter by internal label (case-insensitive substring), e.g. 'Garmin CIRQA'"),
@@ -119,8 +182,8 @@ function registerTools(server) {
119
182
  content: z.string().describe("Post text content"),
120
183
  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
184
  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' } }"),
123
- 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."),
185
+ 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."),
186
+ 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 Stories are not supported); '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 several images (or media with type 'carousel') and it publishes as a carousel where the network supports one."),
124
187
  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
188
  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."),
126
189
  campaign: z.string().optional().describe("Campaign tag, e.g. 'garmin-cirqa-2026' (slugified automatically). Sets utm.campaign and derives a utm.content token from the format (e.g. 'ig-carousel', 'fb-static'). Group/filter/bulk-delete by this with list_campaigns, list_posts({campaign}) and delete_posts({campaign})."),
@@ -184,7 +247,7 @@ function registerTools(server) {
184
247
  : post.scheduledFor
185
248
  ? `Scheduled for ${post.scheduledFor}`
186
249
  : "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)}` }] };
250
+ 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
251
  });
189
252
  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
253
  id: z.string().describe("Post ID to preview"),
@@ -209,7 +272,10 @@ function registerTools(server) {
209
272
  pl.content.length > 400 ? `${pl.content.slice(0, 400)}…` : pl.content,
210
273
  ].filter(Boolean).join("\n");
211
274
  });
212
- const header = `Preview — postType=${p.postType}${p.label ? ` · ${p.label}` : ""}${p.campaign ? ` · campaign=${p.campaign}` : ""}`;
275
+ const blockedLine = p.preflight && p.preflight.ok === false
276
+ ? `\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`
277
+ : "";
278
+ const header = `Preview — postType=${p.postType}${p.label ? ` · ${p.label}` : ""}${p.campaign ? ` · campaign=${p.campaign}` : ""}${p.status ? ` · status=${p.status}` : ""}${blockedLine}`;
213
279
  return {
214
280
  content: [{
215
281
  type: "text",
@@ -227,7 +293,9 @@ function registerTools(server) {
227
293
  const data = await api("/posts/bulk", { method: "POST", body: JSON.stringify(body) });
228
294
  const d = data.data;
229
295
  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))` : ""}`
296
+ ? ` #${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
297
+ ? `\n ⛔ saved as draft: ${r.preflightErrors.map((e) => `[${e.code}] ${e.message}${e.fix ? ` Fix: ${e.fix}` : ""}`).join(" | ")}`
298
+ : ""}`
231
299
  : ` #${r.index} FAILED ${r.error}`);
232
300
  return {
233
301
  content: [{
@@ -290,7 +358,7 @@ function registerTools(server) {
290
358
  content: z.string().optional().describe("New post text content"),
291
359
  connectionIds: z.array(z.string()).optional().describe("New connection IDs"),
292
360
  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 } })"),
361
+ 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
362
  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
363
  threadSegments: z.array(z.string()).optional().describe("Ordered tweet segments for a thread (postType='thread'). 2–25 segments, each 1–280 characters."),
296
364
  label: z.string().nullable().optional().describe("Internal working name shown in the dashboard. Pass null to clear. Never published."),
@@ -353,7 +421,7 @@ function registerTools(server) {
353
421
  if (timezone !== undefined)
354
422
  body.timezone = timezone;
355
423
  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)}` }] };
424
+ 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
425
  });
358
426
  server.tool("delete_post", "Delete a post.", {
359
427
  id: z.string().describe("Post ID to delete"),
@@ -361,6 +429,84 @@ function registerTools(server) {
361
429
  await api(`/posts/${id}`, { method: "DELETE" });
362
430
  return { content: [{ type: "text", text: `Post ${id} deleted.` }] };
363
431
  });
432
+ 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.", {
433
+ id: z.string().describe("Post ID with status failed or partially_published"),
434
+ }, async ({ id }) => {
435
+ let data;
436
+ try {
437
+ data = await api(`/posts/${id}/retry`, { method: "POST" });
438
+ }
439
+ catch (e) {
440
+ return { content: [{ type: "text", text: `Retry refused: ${e?.message ?? e}` }] };
441
+ }
442
+ const retried = Array.isArray(data.retried) && data.retried.length ? data.retried.join(", ") : "nothing";
443
+ const blocked = Array.isArray(data.blocked) && data.blocked.length
444
+ ? `\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")}`
445
+ : "";
446
+ return { content: [{ type: "text", text: `Retrying: ${retried}.${blocked}${formatLinks(data.links)}\n\n${JSON.stringify(data.data, null, 2)}` }] };
447
+ });
448
+ 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.", {
449
+ days: z.number().int().min(1).max(30).optional().describe("Look-back window in days for failures and connection changes (default 1)"),
450
+ }, async ({ days }) => {
451
+ const data = await api(`/delivery-report?days=${days ?? 1}`);
452
+ const r = data.data ?? {};
453
+ const label = (p) => p;
454
+ const lines = [];
455
+ if (!r.hasIssues) {
456
+ 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.`);
457
+ }
458
+ else {
459
+ if (Array.isArray(r.failedPosts) && r.failedPosts.length) {
460
+ lines.push(`Did not go out (${r.failedPosts.length}):`);
461
+ for (const p of r.failedPosts) {
462
+ lines.push(` - ${p.postId} [${p.status}${p.status === "partially_published" ? ` ${p.liveCount}/${p.targetCount} live` : ""}] ${p.content}`);
463
+ for (const f of p.failures ?? []) {
464
+ lines.push(` ${label(f.provider)}: [${f.errorCode ?? f.errorClass ?? "failed"}] ${f.error ?? ""}${f.fix ? ` Fix: ${f.fix}` : ""}`);
465
+ }
466
+ }
467
+ }
468
+ if (Array.isArray(r.connections) && r.connections.length) {
469
+ lines.push(`Connections (${r.connections.length}):`);
470
+ for (const c of r.connections) {
471
+ lines.push(` - ${c.provider} @${c.username} is ${c.status}${c.reason ? ` [${c.reason}]` : ""}${c.lastError ? `: ${c.lastError}` : ""}${c.status === "dead" ? " Fix: reconnect on /connections." : ""}`);
472
+ }
473
+ }
474
+ if (Array.isArray(r.approvals) && r.approvals.length) {
475
+ lines.push(`Approvals (${r.approvals.length}):`);
476
+ for (const a of r.approvals) {
477
+ lines.push(` - ${a.approvalId} post ${a.postId} ${a.overdue ? "SLOT PASSED" : `due ${a.scheduledFor}`}${a.reviewerEmail ? ` (reviewer ${a.reviewerEmail})` : ""}: ${a.content}`);
478
+ }
479
+ }
480
+ if (Array.isArray(r.expiredApprovals) && r.expiredApprovals.length) {
481
+ lines.push(`Approvals that expired, posts back to draft (${r.expiredApprovals.length}):`);
482
+ for (const a of r.expiredApprovals) {
483
+ 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.`);
484
+ }
485
+ }
486
+ if (Array.isArray(r.atRisk) && r.atRisk.length) {
487
+ lines.push(`Scheduled posts that will fail (${r.atRisk.length}):`);
488
+ for (const p of r.atRisk) {
489
+ lines.push(` - ${p.postId} at ${p.scheduledFor}: ${p.content}`);
490
+ for (const i of p.issues ?? []) {
491
+ lines.push(` ${i.platform}: [${i.code}] ${i.message}${i.fix ? ` Fix: ${i.fix}` : ""}`);
492
+ }
493
+ }
494
+ }
495
+ }
496
+ if (Array.isArray(r.staleSources) && r.staleSources.length) {
497
+ lines.push(`Content sources that stopped importing (${r.staleSources.length}):`);
498
+ for (const src of r.staleSources) {
499
+ 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.`);
500
+ }
501
+ }
502
+ if (Array.isArray(r.staleQueues) && r.staleQueues.length) {
503
+ lines.push(`Posting queues with empty slots while drafts wait (${r.staleQueues.length}):`);
504
+ for (const q of r.staleQueues) {
505
+ 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.`);
506
+ }
507
+ }
508
+ return { content: [{ type: "text", text: `${lines.join("\n")}\n\n${JSON.stringify(r, null, 2)}` }] };
509
+ });
364
510
  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
511
  id: z.string().describe("Post ID to publish now"),
366
512
  }, async ({ id }) => {
@@ -370,8 +516,27 @@ function registerTools(server) {
370
516
  server.tool("list_connections", "List all connected social media accounts (Instagram, Twitter, LinkedIn, etc.).", {}, async () => {
371
517
  const data = await api("/connections");
372
518
  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)}` }] };
519
+ // Health comes from the publishing worker and token refresher: a `dead`
520
+ // account fails preflight for every post that targets it until `reason`
521
+ // is fixed, so say so next to the name instead of burying it in JSON.
522
+ const healthTag = (c) => {
523
+ const h = c.health;
524
+ if (!h || h.status === "healthy")
525
+ return "";
526
+ if (h.status === "degraded")
527
+ return " ⚠️ last publish failed, will retry";
528
+ return ` ⛔ cannot publish${h.reason ? ` [${h.reason}]` : ""}${h.lastError ? `: ${String(h.lastError).slice(0, 120)}` : ""}`;
529
+ };
530
+ // `planBlocked` means the platform is not on the account's current plan
531
+ // (X needs a paid plan). The account is connected and healthy, but a post
532
+ // that targets it fails preflight with `x_requires_paid_plan`, so flag it
533
+ // here before the agent picks it.
534
+ const planTag = (c) => c.planBlocked ? ` ⛔ not on the current plan, needs ${c.requiredPlan === "pro" ? "Pro" : "Creator or Pro"}` : "";
535
+ const summary = connections.map((c) => `${c.provider} — ${c.metadata?.accountName || c.providerUsername} (ID: ${c.id})${planTag(c) || healthTag(c)}`).join("\n");
536
+ const dead = connections.filter((c) => c.health?.status === "dead").length;
537
+ const planBlocked = connections.filter((c) => c.planBlocked).length;
538
+ const headline = `${connections.length} connected accounts${dead ? `, ${dead} cannot publish until fixed` : ""}${planBlocked ? `, ${planBlocked} not on the current plan` : ""}:`;
539
+ return { content: [{ type: "text", text: `${headline}\n\n${summary}\n\nFull data:\n${JSON.stringify(connections, null, 2)}` }] };
375
540
  });
376
541
  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
542
  const data = await api("/account-groups");
@@ -837,23 +1002,30 @@ function readBody(req) {
837
1002
  * Remote/streamable-HTTP mode: `planpost-mcp --http [--port 3020]`.
838
1003
  *
839
1004
  * 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.
1005
+ * caller's credential is scoped to that request via AsyncLocalStorage. That
1006
+ * makes one hosted instance safely multi-tenant.
1007
+ *
1008
+ * Two ways to authenticate:
1009
+ * - `Authorization: Bearer pk_...` — a PlanPost API key, always accepted
1010
+ * (e.g. `claude mcp add --transport http planpost https://host/mcp
1011
+ * --header "Authorization: Bearer pk_..."`).
1012
+ * - OAuth, when configured (see oauth.ts): a request without credentials is
1013
+ * answered 401 with a pointer to the protected-resource metadata, the
1014
+ * client signs the user in at planpo.st, and comes back with a token.
845
1015
  */
846
1016
  async function startHttp(port, host) {
1017
+ const oauth = loadOAuthSettings(process.env, BASE_URL);
1018
+ const verifier = oauth ? new AccessTokenVerifier(oauth) : null;
847
1019
  const httpServer = createHttpServer(async (req, res) => {
848
1020
  // An API host must never rank: Google indexed the bare /mcp 405 JSON as a
849
1021
  // page under the brand. The header, not robots.txt, is the fix on purpose:
850
1022
  // a crawler has to be able to fetch the URL to see the noindex, so
851
1023
  // robots.txt below allows crawling and this header does the de-indexing.
852
1024
  res.setHeader("X-Robots-Tag", "noindex, nofollow");
853
- const fail = (status, message) => {
1025
+ const fail = (status, message, headers = {}) => {
854
1026
  // charset is load-bearing: without it, any non-ASCII byte in a message
855
1027
  // renders as mojibake in browsers that assume latin-1 for JSON.
856
- res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
1028
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", ...headers });
857
1029
  res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }));
858
1030
  };
859
1031
  try {
@@ -876,6 +1048,28 @@ async function startHttp(port, host) {
876
1048
  res.end();
877
1049
  return;
878
1050
  }
1051
+ if (url.pathname === "/.well-known/openai-apps-challenge" && process.env.OPENAI_APPS_CHALLENGE) {
1052
+ // Domain verification for the ChatGPT plugin directory: OpenAI issues
1053
+ // a token at submission time and fetches it from here as plain text.
1054
+ res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
1055
+ res.end(process.env.OPENAI_APPS_CHALLENGE);
1056
+ return;
1057
+ }
1058
+ if (oauth && url.pathname.startsWith("/.well-known/oauth-protected-resource")) {
1059
+ // Served at both the path-suffixed and the root well-known URL:
1060
+ // clients try them in that order when the 401 challenge is missing.
1061
+ const suffix = url.pathname.slice("/.well-known/oauth-protected-resource".length);
1062
+ if (suffix !== "" && suffix !== oauth.resourceUrl.pathname) {
1063
+ return fail(404, "Not found. The MCP endpoint is /mcp");
1064
+ }
1065
+ res.writeHead(200, {
1066
+ "Content-Type": "application/json; charset=utf-8",
1067
+ "Access-Control-Allow-Origin": "*",
1068
+ "Cache-Control": "public, max-age=3600",
1069
+ });
1070
+ res.end(JSON.stringify(protectedResourceMetadata(oauth)));
1071
+ return;
1072
+ }
879
1073
  if (url.pathname !== "/mcp")
880
1074
  return fail(404, "Not found. The MCP endpoint is /mcp");
881
1075
  if (req.method !== "POST") {
@@ -883,12 +1077,46 @@ async function startHttp(port, host) {
883
1077
  return fail(405, "Method not allowed. This stateless transport accepts POST only; setup guide at https://planpo.st/mcp");
884
1078
  }
885
1079
  const auth = req.headers.authorization || "";
886
- const key = auth.startsWith("Bearer ") ? auth.slice(7) : "";
887
- if (!key.startsWith("pk_")) {
1080
+ const bearer = /^Bearer /i.test(auth) ? auth.slice(7).trim() : "";
1081
+ let credential;
1082
+ if (bearer.startsWith("pk_")) {
1083
+ credential = { kind: "key", key: bearer };
1084
+ }
1085
+ else if (!oauth || !verifier) {
888
1086
  return fail(401, "Provide your PlanPost API key: Authorization: Bearer pk_...");
889
1087
  }
1088
+ else if (!bearer) {
1089
+ // The challenge is what starts the OAuth flow in the client.
1090
+ return fail(401, "Sign in to PlanPost to use this server, or send an API key: Authorization: Bearer pk_...", { "WWW-Authenticate": bearerChallenge(oauth) });
1091
+ }
1092
+ else {
1093
+ try {
1094
+ const authInfo = await verifier.verify(bearer);
1095
+ // The transport hands this to tool handlers as `extra.authInfo`.
1096
+ req.auth = authInfo;
1097
+ credential = {
1098
+ kind: "grant",
1099
+ grantId: String(authInfo.extra?.grantId),
1100
+ serviceSecret: oauth.serviceSecret,
1101
+ };
1102
+ }
1103
+ catch (err) {
1104
+ if (err instanceof InvalidTokenError) {
1105
+ return fail(401, err.message, {
1106
+ "WWW-Authenticate": bearerChallenge(oauth, err.message),
1107
+ });
1108
+ }
1109
+ if (err instanceof IntrospectionUnavailableError) {
1110
+ // Not the client's fault: a 401 here would make it throw away a
1111
+ // good token and send the user through sign-in again.
1112
+ console.error("[planpost-mcp]", err.message);
1113
+ return fail(503, "PlanPost sign-in is temporarily unavailable. Try again shortly.");
1114
+ }
1115
+ throw err;
1116
+ }
1117
+ }
890
1118
  const body = await readBody(req);
891
- const server = buildServer();
1119
+ const server = buildServer(credential.kind === "grant" ? OMITTED_FOR_SIGNED_IN_AGENTS : NO_TOOLS);
892
1120
  const transport = new StreamableHTTPServerTransport({
893
1121
  sessionIdGenerator: undefined, // stateless
894
1122
  });
@@ -897,7 +1125,7 @@ async function startHttp(port, host) {
897
1125
  server.close();
898
1126
  });
899
1127
  await server.connect(transport);
900
- await requestApiKey.run(key, () => transport.handleRequest(req, res, body));
1128
+ await requestCredential.run(credential, () => transport.handleRequest(req, res, body));
901
1129
  }
902
1130
  catch (err) {
903
1131
  console.error("[planpost-mcp] request error:", err);
@@ -906,7 +1134,8 @@ async function startHttp(port, host) {
906
1134
  }
907
1135
  });
908
1136
  httpServer.listen(port, host, () => {
909
- console.log(`planpost-mcp listening on http://${host}:${port}/mcp (streamable HTTP, stateless)`);
1137
+ console.log(`planpost-mcp listening on http://${host}:${port}/mcp (streamable HTTP, stateless; ` +
1138
+ (oauth ? `OAuth via ${oauth.issuer} + API keys)` : "API keys only)"));
910
1139
  });
911
1140
  }
912
1141
  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.1",
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,107 @@
1
+ ---
2
+ name: planpost
3
+ description: Draft, schedule and publish social media posts through PlanPost, the agentic social scheduler your AI agent can run, then check what went out and what worked. Use when the user wants to post, schedule, queue or bulk-plan content for Instagram, TikTok, LinkedIn, X (Twitter), Facebook, Threads or YouTube, 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, reports what
12
+ failed and why, and reads back analytics and revenue (revenue per post on the
13
+ Pro plan). This skill is the operating manual for its
14
+ MCP tools. Tool names below are the ones the server exposes; your client may
15
+ prefix them (for example `mcp__planpost__create_post`).
16
+
17
+ ## Before anything else
18
+
19
+ If no PlanPost tools are available, the server is not connected. Tell the user
20
+ to add `https://mcp.planpo.st/mcp` as a remote MCP server (connector) in their
21
+ agent and sign in when the browser opens. Details per client:
22
+ https://planpo.st/docs. Do not ask the user to paste an API key into
23
+ the chat.
24
+
25
+ ## The rules that prevent bad posts
26
+
27
+ 1. **Resolve targets, never guess them.** Call `list_account_groups` (named
28
+ sets such as "Agency clients") or `list_connections` and use the IDs they
29
+ return. If the user names a group or handle you cannot find, show what
30
+ exists and ask.
31
+ 2. **Draft unless told otherwise.** `create_post` with no `scheduledFor` saves
32
+ a draft. Schedule only when the user gave a time, and publish immediately
33
+ (`publish_post`) only when they said "now" in so many words.
34
+ 3. **Say the time back.** Pass `scheduledFor` as ISO 8601 plus the user's
35
+ `timezone`, and repeat the local time in your answer. If they gave no time
36
+ and want one, `suggest_schedule` or `get_best_times` picks it.
37
+ 4. **Approval is not a failure.** If the user turned on approval for this
38
+ connection, a post comes back `needs_approval`: it is waiting for them in
39
+ PlanPost under Posts, In review. Report that and stop; do not retry or
40
+ re-create it. `get_approval_status` tells you when it clears.
41
+ 5. **Read warnings.** Responses carry `warnings[]` (what will be truncated or
42
+ dropped at publish time) and sometimes `preflight.errors[]` (the post was
43
+ saved as a draft because it would fail). Fix what they name, then
44
+ `update_post`.
45
+ 6. **X (Twitter) takes no links.** No URL, bare domain or t.co in the post,
46
+ any thread segment, or the first comment. Put a link-free variant in
47
+ `platformContent.twitter`.
48
+ 7. **TikTok needs a visibility choice.** A scheduled or published TikTok post
49
+ must carry `platformSettings.tiktok.privacyLevel`. Ask the user if they
50
+ have not said.
51
+
52
+ ## Common jobs
53
+
54
+ **Post one thing to several networks**
55
+
56
+ 1. `list_account_groups` → pick the group, or `list_connections` → pick IDs.
57
+ 2. `preview_post` with the text to see per-network length and media problems.
58
+ 3. `create_post` with `content`, `connectionIds`, optional
59
+ `platformContent` overrides and `scheduledFor`.
60
+ 4. Give the user the status and the `Open in PlanPost` link from the response.
61
+
62
+ **Plan a week or a campaign**
63
+
64
+ Use `bulk_create_posts` with one manifest instead of looping `create_post`.
65
+ Give every post the same `campaign` so `list_posts` and `list_campaigns` can
66
+ find them again. To fill the user's standing queue instead of picking times,
67
+ read `get_posting_schedule` first.
68
+
69
+ **Attach media**
70
+
71
+ Reuse before uploading: `list_media` shows what is already in the library.
72
+ For a new file, `upload_media` returns a presigned URL; upload to it, then
73
+ pass the returned asset in `media`.
74
+
75
+ Several images attached to one post publish as a carousel where the network
76
+ supports one. For a Reel or Short, attach the video and set `postType: "reel"`
77
+ or `"short"`.
78
+
79
+ **Turn an article into posts**
80
+
81
+ Read the article (open the URL the user gives you, or ask them to paste it) →
82
+ write per-network variants (use `get_brand_voice_prompt` when a brand voice
83
+ exists) → `create_post` as drafts.
84
+
85
+ **A post failed**
86
+
87
+ `get_post` → read `platformResults[]`: `errorClass`, `error`, `fix`.
88
+ `retry_failed` re-queues only transient failures and never re-posts a network
89
+ that already went out. Failures that need the user (reconnect an account,
90
+ choose TikTok visibility) come back with the fix to relay.
91
+
92
+ **What worked?**
93
+
94
+ `get_analytics` for reach and engagement, `get_revenue_overview` for revenue,
95
+ signups and clicks attributed to posts. Lead with the posts that earned
96
+ money, not the ones with the most likes.
97
+
98
+ ## Limits to respect
99
+
100
+ - Calls count against the user's monthly API allowance; prefer one
101
+ `bulk_create_posts` over many `create_post` calls, and do not poll a status
102
+ faster than every few seconds.
103
+ - A tool that answers with a missing-permission error means the user did not
104
+ grant that scope. Say which capability is missing; do not look for a
105
+ workaround.
106
+ - Connecting a new social account needs a human in a browser:
107
+ `get_connection_link` returns the link to send them.
@@ -1,152 +0,0 @@
1
- ---
2
- name: send-to-plan-post
3
- description: Compose and route social posts through the user's PlanPost app. Resolves named account groups ("Polish Pages", "English Pages", "SaaS", "Personal", etc.) into PlanPost connection IDs and creates draft, scheduled, or immediately-published posts. MANDATORY TRIGGERS - "send to plan post", "/send-to-plan-post", "post to PlanPost", "publish via PlanPost", "save as draft on PlanPost", "post to my <group> pages", "post this to my <group> group", "draft on PlanPost", "schedule on PlanPost", "post to Polish Pages", "post to English Pages". Also trigger when the user pastes social copy and references any known group name. Requires the planpost-mcp server registered with PLANPOST_API_KEY.
4
- ---
5
-
6
- # send-to-plan-post
7
-
8
- ## What this skill does
9
-
10
- Routes user-provided content to the right set of social accounts in PlanPost, using account groups as the primary targeting concept (with a fallback to individual handle targeting). Supports draft, scheduled, and immediate-publish modes. Handles image/video attachments via PlanPost's presigned S3 upload flow.
11
-
12
- ## Required MCP tools (`planpost-mcp`)
13
-
14
- The user must have the `planpost-mcp` server installed and authenticated with `PLANPOST_API_KEY`. This skill calls:
15
-
16
- - `list_account_groups` — resolve a group name into its connections
17
- - `get_account_group` — fetch one group (by id, name, or default flag)
18
- - `list_connections` — fallback when targeting individual accounts
19
- - `upload_media` — presigned-URL upload for attached files
20
- - `create_post` — draft or scheduled creation
21
- - `publish_post` — immediate publish of a created post
22
- - (read-only) `list_posts`, `get_post` — for "show me what I just sent"
23
-
24
- The API key needs these permissions: `groups:read`, `connections:read`, `posts:read`, `posts:write`, `media:write`.
25
-
26
- If the MCP server is missing or the API key is unset, stop and tell the user how to install/configure it (see `packages/mcp-server/README.md`). Do not fall back to web scraping or computer-use clicking through the dashboard.
27
-
28
- ## Flow
29
-
30
- ### Step 1 — Parse the request
31
-
32
- From the user's message extract:
33
-
34
- - **Content** — the post body. Preserve the user's language exactly (Polish stays Polish, English stays English). Do not "improve" copy unless asked.
35
- - **Target** — one of: a group name ("Polish Pages"), a list of handles ("@triathlon_info_pl on IG"), or "default".
36
- - **Mode** — `draft` | `schedule` | `publish-now`. **Default to `draft` when ambiguous.** Only schedule or publish if the user explicitly asks for it.
37
- - **Schedule time** — if mode is `schedule`, an ISO datetime or natural phrase ("tomorrow 9am"). Resolve to ISO 8601 in the user's timezone before sending.
38
- - **Media** — any files attached in the chat or URLs the user pasted.
39
- - **Platform overrides** — per-platform variants (`platformContent`) or settings (`platformSettings`, e.g. `{ instagram: { isReel: true } }`) only if the user gave them explicitly.
40
-
41
- ### Step 2 — Resolve targets
42
-
43
- - **Group by name** → call `list_account_groups`, do a case-insensitive exact-name match first; if no hit, try substring; if multiple match, ask the user to pick from the matches.
44
- - **Default group** → call `get_account_group` with `{ default: true }`.
45
- - **Specific handles** → call `list_connections`, filter by `provider` + `providerUsername` (lowercase compare, strip leading `@`).
46
- - **No target resolved** → stop, list every group the user has, ask which one.
47
-
48
- Capture the resolved `connectionIds[]` and the human-readable list (`provider @handle`) for the confirmation step.
49
-
50
- ### Step 3 — Handle media
51
-
52
- For each attached file:
53
-
54
- 1. Call `upload_media` with `{ filename, contentType, size }` to get a presigned PUT URL.
55
- 2. PUT the file bytes.
56
- 3. Capture the returned `{ id, type, url, key, filename, mimeType, size }` and append to a `media[]` array.
57
-
58
- For pasted URLs: fetch the bytes (respect link-safety rules — verify unfamiliar URLs with the user before fetching), then upload via the same flow. If a URL fails to fetch, ask the user to attach the file directly instead.
59
-
60
- ### Step 4 — Compose and confirm
61
-
62
- Before any write that publishes publicly, show a confirmation block:
63
-
64
- ```
65
- About to <draft | schedule | publish-now>:
66
-
67
- Group: Polish Pages (4 accounts)
68
- - facebook @triathloninfopl
69
- - instagram @triathlon_info_pl
70
- - threads @triathlon_info_pl
71
- - twitter @triathlon_info_pl
72
- When: <Now | 2026-05-19 09:00 Europe/Warsaw | Draft only>
73
- Content: <body, truncated to ~200 chars with [...] if longer>
74
- Media: <N items, names>
75
- Mode: <publish-now | schedule | draft>
76
-
77
- Reply "go" to execute, or describe edits.
78
- ```
79
-
80
- Rules:
81
-
82
- - **Draft**: a confirmation summary is shown but the skill may proceed without an explicit "go" if the user's instruction was already unambiguous. (Drafts are reversible.)
83
- - **Schedule**: require explicit "go".
84
- - **Publish-now**: require explicit "go". If 5+ accounts are targeted, require a second `"yes publish to all <N>"` confirmation.
85
-
86
- ### Step 5 — Execute
87
-
88
- - Call `create_post` with `{ content, connectionIds, media, scheduledFor?, platformContent?, platformSettings?, timezone? }`.
89
- - If mode is `publish-now`: take the returned post id and call `publish_post`.
90
- - If mode is `schedule`: setting `scheduledFor` on `create_post` is enough; do not call `publish_post`.
91
- - If mode is `draft`: do not set `scheduledFor` and do not call `publish_post`.
92
-
93
- ### Step 6 — Report back
94
-
95
- Format the result tightly:
96
-
97
- ```
98
- Created post <id> as <status>.
99
-
100
- Targets:
101
- - facebook @triathloninfopl queued
102
- - instagram @triathlon_info_pl queued
103
- - threads @triathlon_info_pl failed (token expired — re-auth at planpo.st)
104
-
105
- Dashboard: https://planpo.st/posts/<id>
106
- ```
107
-
108
- Surface per-platform results (`platformResults[]`) from the API response. **Do not swallow partial failures.**
109
-
110
- ## Examples
111
-
112
- ### Draft to a group
113
-
114
- > "draft a post on PlanPost to my English Pages — 'New SoloOperatorStack post on building Claude skills over PlanPost. Link in bio.'"
115
-
116
- → Resolve "English Pages" via `list_account_groups`. Show confirmation. Call `create_post` with no `scheduledFor`. Report id.
117
-
118
- ### Publish-now to specific handles
119
-
120
- > "post to @triathlon_info_pl on Instagram and Facebook only: 'Trening interwałowy 6x800 — kto dziś biegnie?'"
121
-
122
- → Skip group resolution. Filter `list_connections` to those two. Confirm (4+ rule does not apply). `create_post` → `publish_post`.
123
-
124
- ### Schedule with image
125
-
126
- > User drops `cover.jpg`: "schedule this for tomorrow 9am to Polish Pages: 'Lista startów na weekend ...'"
127
-
128
- → `upload_media` for the image. Resolve Polish Pages via `get_account_group`. Resolve "tomorrow 9am" to ISO in `Europe/Warsaw`. Confirm. `create_post` with `scheduledFor`.
129
-
130
- ### Discovery
131
-
132
- > "what groups do I have on PlanPost?"
133
-
134
- → `list_account_groups`. Format as table: name, account count, default flag, color.
135
-
136
- ## Safety rules
137
-
138
- - Never publish without explicit "go" / "publish" / "yes" from the user.
139
- - Never invent a group name — if the user names a group that doesn't exist, list what does exist and ask.
140
- - Never silently drop a failed-per-account result — always surface it in the final report.
141
- - Preserve the user's language and tone in the post body. Do not paraphrase.
142
- - For pasted URLs, follow link-safety rules: confirm unfamiliar URLs with the user before fetching their contents.
143
-
144
- ## Failure modes and how to handle them
145
-
146
- - **API key missing** → "Set `PLANPOST_API_KEY` in your MCP config. See `packages/mcp-server/README.md`."
147
- - **`groups:read` permission missing** → "Your API key doesn't have the `groups:read` permission. Regenerate it at planpo.st/settings/api-keys with that scope checked, or pass connection IDs directly via `list_connections`."
148
- - **Group not found** → "I don't see a group called 'X'. You have: <list>. Want me to use one of these?"
149
- - **Ambiguous group name (HTTP 300)** → list the candidates returned by the API, ask the user to pick by id.
150
- - **Connection inactive (token expired)** → call this out before composing; suggest re-auth in the dashboard.
151
- - **Upload fails** → retry once, then ask the user to attach again or paste a different URL.
152
- - **Backend 5xx on `create_post`** → do not retry automatically. Confirm with the user first to avoid duplicate posts.