planpost-mcp 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.2.1",
4
- "//mcpName": "Ownership marker for the official MCP Registry: must equal `name` in server.json, or publishing fails validation.",
5
- "mcpName": "st.planpo/mcp",
6
- "description": "PlanPost MCP server — let Claude and other AI agents schedule posts, manage social media, create videos and carousels, and read analytics via the PlanPost public API",
3
+ "version": "0.4.0",
4
+ "//mcpName": "Ownership marker for the official MCP Registry: must equal `name` in server.json, or `mcp-publisher publish` fails validation. Namespace is the GitHub account that owns this repo, verified with `mcp-publisher login github`.",
5
+ "mcpName": "io.github.lukBB/planpost",
6
+ "description": "PlanPost MCP server, 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"
@@ -19,28 +20,52 @@
19
20
  "engines": {
20
21
  "node": ">=18"
21
22
  },
22
- "homepage": "https://planpo.st/agent",
23
+ "homepage": "https://planpo.st/mcp",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/lukBB/planpost-mcp.git"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/lukBB/planpost-mcp/issues"
30
+ },
23
31
  "keywords": [
24
32
  "mcp",
25
33
  "model-context-protocol",
26
- "claude",
27
- "ai-agent",
34
+ "mcp-server",
28
35
  "social-media",
36
+ "social-media-scheduling",
29
37
  "scheduling",
38
+ "instagram",
39
+ "tiktok",
40
+ "linkedin",
41
+ "twitter",
42
+ "facebook",
43
+ "threads",
44
+ "youtube",
45
+ "claude",
46
+ "claude-code",
47
+ "cursor",
48
+ "codex",
49
+ "gemini-cli",
50
+ "ai-agent",
30
51
  "planpost"
31
52
  ],
32
53
  "scripts": {
33
54
  "build": "tsc",
34
55
  "dev": "tsc --watch",
35
56
  "start": "node dist/index.js",
36
- "prepublishOnly": "npm run build"
57
+ "prepublishOnly": "npm run build",
58
+ "pretest": "npm run build",
59
+ "test": "node --test test/*.test.mjs"
37
60
  },
38
61
  "dependencies": {
39
62
  "@modelcontextprotocol/sdk": "^1.12.1",
40
63
  "zod": "^4.3.6"
41
64
  },
42
65
  "devDependencies": {
43
- "typescript": "^5.7.0",
44
- "@types/node": "^22.0.0"
66
+ "@types/node": "^22.0.0",
67
+ "ajv": "^8.17.1",
68
+ "ajv-formats": "^3.0.1",
69
+ "typescript": "^5.7.0"
45
70
  }
46
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.