planpost-mcp 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/README.md +228 -16
- package/dist/annotations.d.ts +3 -0
- package/dist/annotations.js +66 -0
- package/dist/index.js +271 -39
- package/dist/oauth.d.ts +73 -0
- package/dist/oauth.js +122 -0
- package/package.json +36 -11
- package/skills/planpost/SKILL.md +114 -0
package/dist/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.
|
|
4
|
-
"//mcpName": "Ownership marker for the official MCP Registry: must equal `name` in server.json, or
|
|
5
|
-
"mcpName": "
|
|
6
|
-
"description": "PlanPost MCP server
|
|
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/
|
|
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
|
-
"
|
|
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
|
-
"
|
|
44
|
-
"
|
|
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.
|