@post-ripple/mcp 0.1.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/README.md +86 -0
- package/dist/index.js +176 -0
- package/package.json +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# @post-ripple/mcp
|
|
2
|
+
|
|
3
|
+
MCP server that lets AI agents (Claude Code, Codex, opencode, and any other MCP client) manage a [Post Ripple](https://postripple.app) workspace: import and organize videos, run rotation groups, schedule posts to TikTok / Instagram Reels / YouTube Shorts, and read engagement analytics.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
1. In Post Ripple, go to **Settings → API Keys** (Pro plan and up) and create a key. Pick scopes:
|
|
8
|
+
- `read` — list content, posts, accounts, analytics
|
|
9
|
+
- `content:write` — create/organize videos, groups, schedules
|
|
10
|
+
- `publish` — schedule/cancel posts to real social accounts (off by default; enable deliberately)
|
|
11
|
+
2. Give your agent the key as the `POSTRIPPLE_API_KEY` environment variable. That's the only configuration needed.
|
|
12
|
+
|
|
13
|
+
### Claude Code
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
claude mcp add postripple \
|
|
17
|
+
-e POSTRIPPLE_API_KEY=pr_... \
|
|
18
|
+
-- npx -y @post-ripple/mcp@latest
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Codex
|
|
22
|
+
|
|
23
|
+
`~/.codex/config.toml`:
|
|
24
|
+
|
|
25
|
+
```toml
|
|
26
|
+
[mcp_servers.postripple]
|
|
27
|
+
command = "npx"
|
|
28
|
+
args = ["-y", "@post-ripple/mcp@latest"]
|
|
29
|
+
env = { POSTRIPPLE_API_KEY = "pr_..." }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### opencode
|
|
33
|
+
|
|
34
|
+
`opencode.json`:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcp": {
|
|
39
|
+
"postripple": {
|
|
40
|
+
"type": "local",
|
|
41
|
+
"command": ["npx", "-y", "@post-ripple/mcp@latest"],
|
|
42
|
+
"environment": {
|
|
43
|
+
"POSTRIPPLE_API_KEY": "pr_..."
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Tools
|
|
51
|
+
|
|
52
|
+
| Tool | Scope | What it does |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `whoami` | read | Workspace overview: org, plan, accounts, key scopes |
|
|
55
|
+
| `list_social_accounts` | read | Connected accounts with token health |
|
|
56
|
+
| `list_videos` | read | Video library (status, source) |
|
|
57
|
+
| `list_video_groups` / `get_video_group` | read | Rotation groups and their posting rules |
|
|
58
|
+
| `list_slideshows` | read | Photo-carousel slideshows |
|
|
59
|
+
| `list_posts` | read | Posts with status, schedule, errors |
|
|
60
|
+
| `get_post_metrics` | read | Per-post engagement time series |
|
|
61
|
+
| `get_account_metrics` | read | Follower growth history |
|
|
62
|
+
| `get_engagement_summary` | read | Org-wide totals, per-platform breakdown, top posts |
|
|
63
|
+
| `import_video_from_url` | content:write | Import a video from a URL (async download) |
|
|
64
|
+
| `get_video_upload_url` / `register_uploaded_video` | content:write | Upload a local video file |
|
|
65
|
+
| `create_video_group` | content:write | Create a rotation group |
|
|
66
|
+
| `add_videos_to_group` | content:write | Fill a group's rotation |
|
|
67
|
+
| `update_group_schedule` | content:write | Attach accounts / set posts-per-day and time windows |
|
|
68
|
+
| `schedule_post` | publish | Queue a video to publish at a future time (idempotent via `idempotencyKey`) |
|
|
69
|
+
| `reschedule_post` | publish | Move a scheduled post |
|
|
70
|
+
| `cancel_post` | publish | Cancel a scheduled post (refunds the credit) |
|
|
71
|
+
|
|
72
|
+
Notes for agents:
|
|
73
|
+
|
|
74
|
+
- `schedule_post` publishes to a **real** social account. Always pass `idempotencyKey`, and pass `tiktokSettings` if the post should be public on TikTok (the default is private/`SELF_ONLY`).
|
|
75
|
+
- Imports and publishes are asynchronous. Poll `list_videos` / `list_posts` to observe progress.
|
|
76
|
+
- Every call is audit-logged and attributed to the member who created the key. Keys are org-bound; usage requires the Pro plan or higher.
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
The server talks to `https://api.postripple.app` by default. Set `POSTRIPPLE_API_URL` to target a dev deployment instead:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
npm install
|
|
84
|
+
npm run build
|
|
85
|
+
POSTRIPPLE_API_KEY=pr_... POSTRIPPLE_API_URL=https://<dev-deployment>.convex.site node dist/index.js
|
|
86
|
+
```
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
const DEFAULT_API_URL = "https://api.postripple.app";
|
|
6
|
+
const API_KEY = process.env.POSTRIPPLE_API_KEY;
|
|
7
|
+
// Override only for development against a non-production deployment.
|
|
8
|
+
const API_URL = (process.env.POSTRIPPLE_API_URL ?? DEFAULT_API_URL).replace(/\/+$/, "");
|
|
9
|
+
if (!API_KEY) {
|
|
10
|
+
console.error("POSTRIPPLE_API_KEY is not set. Create a key in Post Ripple under Settings → API Keys (Pro plan and up) and set it in the MCP server env.");
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
async function callApi(tool, body) {
|
|
14
|
+
const response = await fetch(`${API_URL}/agent/v1/${tool}`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: {
|
|
17
|
+
Authorization: `Bearer ${API_KEY}`,
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify(body),
|
|
21
|
+
});
|
|
22
|
+
let payload;
|
|
23
|
+
try {
|
|
24
|
+
payload = (await response.json());
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new Error(`Post Ripple API returned ${response.status} with a non-JSON body`);
|
|
28
|
+
}
|
|
29
|
+
if (!payload.ok || !response.ok) {
|
|
30
|
+
const err = payload.error ?? { code: "unknown", message: `HTTP ${response.status}` };
|
|
31
|
+
throw new Error(`${err.code}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
return payload.data;
|
|
34
|
+
}
|
|
35
|
+
/** Accepts epoch ms or an ISO-8601 string and returns epoch ms. */
|
|
36
|
+
function toEpochMs(value, field) {
|
|
37
|
+
if (typeof value === "number")
|
|
38
|
+
return value;
|
|
39
|
+
const parsed = Date.parse(value);
|
|
40
|
+
if (Number.isNaN(parsed)) {
|
|
41
|
+
throw new Error(`${field} must be epoch milliseconds or an ISO-8601 datetime`);
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
44
|
+
}
|
|
45
|
+
const server = new McpServer({ name: "postripple", version: "0.1.0" });
|
|
46
|
+
function register(name, description, shape, transform) {
|
|
47
|
+
server.tool(name, description, shape, async (args) => {
|
|
48
|
+
try {
|
|
49
|
+
const body = transform ? transform(args) : args;
|
|
50
|
+
const data = await callApi(name, body);
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
return {
|
|
58
|
+
isError: true,
|
|
59
|
+
content: [{ type: "text", text: `Error: ${message}` }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const platformEnum = z.enum(["tiktok", "instagram", "youtube", "other"]);
|
|
65
|
+
const postStatusEnum = z.enum([
|
|
66
|
+
"scheduled",
|
|
67
|
+
"pending",
|
|
68
|
+
"uploading",
|
|
69
|
+
"processing",
|
|
70
|
+
"published",
|
|
71
|
+
"failed",
|
|
72
|
+
"canceled",
|
|
73
|
+
]);
|
|
74
|
+
const videoStatusEnum = z.enum(["pending", "downloading", "completed", "failed"]);
|
|
75
|
+
const scheduledForSchema = z
|
|
76
|
+
.union([z.number(), z.string()])
|
|
77
|
+
.describe("When to publish: epoch milliseconds or ISO-8601 datetime. Must be in the future.");
|
|
78
|
+
const postingWindowSchema = z.object({
|
|
79
|
+
startHour: z.number().min(0).max(23),
|
|
80
|
+
startMinute: z.number().min(0).max(59),
|
|
81
|
+
endHour: z.number().min(0).max(23),
|
|
82
|
+
endMinute: z.number().min(0).max(59),
|
|
83
|
+
});
|
|
84
|
+
const tiktokSettingsSchema = z
|
|
85
|
+
.object({
|
|
86
|
+
privacyLevel: z.enum([
|
|
87
|
+
"PUBLIC_TO_EVERYONE",
|
|
88
|
+
"MUTUAL_FOLLOW_FRIENDS",
|
|
89
|
+
"FOLLOWER_OF_CREATOR",
|
|
90
|
+
"SELF_ONLY",
|
|
91
|
+
]),
|
|
92
|
+
disableComment: z.boolean(),
|
|
93
|
+
disableDuet: z.boolean(),
|
|
94
|
+
disableStitch: z.boolean(),
|
|
95
|
+
brandOrganicToggle: z.boolean(),
|
|
96
|
+
brandContentToggle: z.boolean(),
|
|
97
|
+
})
|
|
98
|
+
.describe("TikTok compliance settings. If omitted, the post publishes as SELF_ONLY (private).");
|
|
99
|
+
// ---- read tools ----
|
|
100
|
+
register("whoami", "Get the connected Post Ripple workspace: organization name, plan, connected social accounts, active group count, scheduled post count, and this key's scopes. Call this first to orient yourself.", {});
|
|
101
|
+
register("list_social_accounts", "List the workspace's connected social accounts (TikTok / Instagram / YouTube) with connection health: status (active/expired/revoked/error), token expiry, and last error. Use the returned accountId for scheduling and metrics tools.", {});
|
|
102
|
+
register("list_videos", "List videos in the workspace library, newest first. Videos with status 'completed' are publishable; 'pending'/'downloading' are still being imported.", {
|
|
103
|
+
status: videoStatusEnum.optional(),
|
|
104
|
+
limit: z.number().min(1).max(200).optional().describe("Default 50, max 200"),
|
|
105
|
+
});
|
|
106
|
+
register("list_video_groups", "List video groups (rotation playlists that auto-post on a schedule) with video/account counts and status.", {});
|
|
107
|
+
register("get_video_group", "Get one video group in detail: its videos and the per-account posting rules (posts per day, posting windows, next scheduled post).", { groupId: z.string() });
|
|
108
|
+
register("list_slideshows", "List photo-carousel slideshows with status ('draft' = still being composed, 'ready' = publishable).", { limit: z.number().min(1).max(200).optional() });
|
|
109
|
+
register("list_posts", "List posts (scheduled, publishing, published, failed, canceled), newest first. Scheduled posts include scheduledFor; published posts include platformPostId and publishedAt.", {
|
|
110
|
+
status: postStatusEnum.optional(),
|
|
111
|
+
platform: platformEnum.optional(),
|
|
112
|
+
limit: z.number().min(1).max(200).optional(),
|
|
113
|
+
});
|
|
114
|
+
register("get_post_metrics", "Get engagement for one published post: latest snapshot (views/likes/comments/shares) plus the time series of scraped metrics.", {
|
|
115
|
+
postId: z.string(),
|
|
116
|
+
limit: z.number().min(1).max(200).optional().describe("Max series points, default 50"),
|
|
117
|
+
});
|
|
118
|
+
register("get_account_metrics", "Get follower/profile growth history for one connected social account (daily snapshots).", {
|
|
119
|
+
accountId: z.string(),
|
|
120
|
+
limit: z.number().min(1).max(200).optional(),
|
|
121
|
+
});
|
|
122
|
+
register("get_engagement_summary", "Get an org-wide engagement overview for the last N days: totals, per-platform breakdown, top 5 posts by views, and current follower counts per account. The best starting point for 'how is content performing?'", { days: z.number().min(1).max(90).optional().describe("Window in days, default 30") });
|
|
123
|
+
// ---- content:write tools ----
|
|
124
|
+
register("import_video_from_url", "Import a video into the library from a public URL (e.g. a TikTok/Instagram/YouTube link or a direct file URL). Download happens asynchronously — poll list_videos until its status is 'completed' before scheduling it.", {
|
|
125
|
+
url: z.string().url(),
|
|
126
|
+
platform: platformEnum
|
|
127
|
+
.optional()
|
|
128
|
+
.describe("Source platform of the URL; helps the downloader. Defaults to 'other'."),
|
|
129
|
+
});
|
|
130
|
+
register("get_video_upload_url", "Get a presigned upload URL for pushing a local video file into the library. PUT the raw video bytes to the returned uploadUrl, then call register_uploaded_video with the returned fileId.", {});
|
|
131
|
+
register("register_uploaded_video", "Register a video after uploading its bytes to a presigned URL from get_video_upload_url. The video becomes immediately publishable.", {
|
|
132
|
+
fileId: z.string().describe("The fileId returned by get_video_upload_url"),
|
|
133
|
+
title: z.string(),
|
|
134
|
+
});
|
|
135
|
+
register("create_video_group", "Create a video group (a shuffle rotation playlist). Attach accounts with update_group_schedule and add videos with add_videos_to_group to start auto-posting. Uses one 'groups' credit from the plan.", {
|
|
136
|
+
name: z.string(),
|
|
137
|
+
description: z.string().optional(),
|
|
138
|
+
});
|
|
139
|
+
register("add_videos_to_group", "Add up to 100 library videos to a group's rotation. Returns which were added and which were skipped (already present / not found).", {
|
|
140
|
+
groupId: z.string(),
|
|
141
|
+
videoIds: z.array(z.string()).min(1).max(100),
|
|
142
|
+
});
|
|
143
|
+
register("update_group_schedule", "Attach a social account to a group or update its posting rules: posts per day (1-24), active/paused, and optional posting time windows (hours in the org's timezone). First call for an account must include postsPerDay.", {
|
|
144
|
+
groupId: z.string(),
|
|
145
|
+
accountId: z.string(),
|
|
146
|
+
postsPerDay: z.number().min(1).max(24).optional(),
|
|
147
|
+
status: z.enum(["active", "paused"]).optional(),
|
|
148
|
+
postingWindows: z.array(postingWindowSchema).optional(),
|
|
149
|
+
});
|
|
150
|
+
// ---- publish tools ----
|
|
151
|
+
register("schedule_post", "Schedule a completed library video to publish on a connected account at a future time. This posts to a REAL social account when the time arrives. The platform is derived from the account. Uses one 'posts' credit (refunded if canceled). ALWAYS pass idempotencyKey (any unique string) so retries can't double-post. For TikTok, pass tiktokSettings to control privacy — omitting it publishes as private (SELF_ONLY). For YouTube, pass title.", {
|
|
152
|
+
videoId: z.string(),
|
|
153
|
+
accountId: z.string(),
|
|
154
|
+
scheduledFor: scheduledForSchema,
|
|
155
|
+
caption: z.string().optional(),
|
|
156
|
+
title: z.string().optional().describe("YouTube title (ignored elsewhere)"),
|
|
157
|
+
tiktokSettings: tiktokSettingsSchema.optional(),
|
|
158
|
+
idempotencyKey: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe("Unique string for safe retries — strongly recommended"),
|
|
162
|
+
}, (args) => ({
|
|
163
|
+
...args,
|
|
164
|
+
scheduledFor: toEpochMs(args.scheduledFor, "scheduledFor"),
|
|
165
|
+
}));
|
|
166
|
+
register("reschedule_post", "Move a still-scheduled post to a new future time. Fails once publishing has started.", {
|
|
167
|
+
postId: z.string(),
|
|
168
|
+
scheduledFor: scheduledForSchema,
|
|
169
|
+
}, (args) => ({
|
|
170
|
+
...args,
|
|
171
|
+
scheduledFor: toEpochMs(args.scheduledFor, "scheduledFor"),
|
|
172
|
+
}));
|
|
173
|
+
register("cancel_post", "Cancel a still-scheduled post and refund its credit. Fails once publishing has started.", { postId: z.string() });
|
|
174
|
+
const transport = new StdioServerTransport();
|
|
175
|
+
await server.connect(transport);
|
|
176
|
+
console.error(`Post Ripple MCP server connected (${API_URL})`);
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@post-ripple/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server that lets AI agents (Claude Code, Codex, opencode) manage a Post Ripple workspace: content, scheduling, publishing, and analytics.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"postripple-mcp": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
25
|
+
"zod": "^3.24.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"typescript": "^5.7.2",
|
|
29
|
+
"@types/node": "^22.10.2"
|
|
30
|
+
}
|
|
31
|
+
}
|