schedpilot-mcp 1.0.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/.env.example +6 -0
- package/package.json +19 -0
- package/server.js +343 -0
package/.env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# SchedPilot API token
|
|
2
|
+
# Option A — Personal API key (created at app.schedpilot.com/api-access)
|
|
3
|
+
SCHEDPILOT_TOKEN=smm_your_api_key_here
|
|
4
|
+
|
|
5
|
+
# Option B — OAuth access token (obtained via OAuth 2.0 Authorization Code flow)
|
|
6
|
+
# SCHEDPILOT_TOKEN=sp_tok_your_oauth_token_here
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "schedpilot-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Model Context Protocol server for SchedPilot — schedule and manage social media posts via AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "server.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"schedpilot-mcp": "./server.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node server.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
15
|
+
"axios": "^1.6.0",
|
|
16
|
+
"form-data": "^4.0.0",
|
|
17
|
+
"zod": "^3.22.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SchedPilot MCP Server
|
|
4
|
+
*
|
|
5
|
+
* Exposes SchedPilot as a Model Context Protocol (MCP) tool server so that
|
|
6
|
+
* AI agents (Claude, OpenClaw, Hermes, Cursor, etc.) can schedule and manage
|
|
7
|
+
* social media posts on the user's behalf.
|
|
8
|
+
*
|
|
9
|
+
* Authentication
|
|
10
|
+
* ──────────────
|
|
11
|
+
* Set SCHEDPILOT_TOKEN in the environment before starting:
|
|
12
|
+
* - Personal API key: smm_xxxxxxxx (created at app.schedpilot.com/api-access)
|
|
13
|
+
* - OAuth token: sp_tok_xxxxxx (obtained via OAuth 2.0 flow)
|
|
14
|
+
*
|
|
15
|
+
* Claude Desktop setup (~/.claude_desktop_config.json)
|
|
16
|
+
* ────────────────────────────────────────────────────
|
|
17
|
+
* {
|
|
18
|
+
* "mcpServers": {
|
|
19
|
+
* "schedpilot": {
|
|
20
|
+
* "command": "node",
|
|
21
|
+
* "args": ["/absolute/path/to/schedpilot-node-server-side/mcp/server.js"],
|
|
22
|
+
* "env": { "SCHEDPILOT_TOKEN": "smm_your_key_here" }
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
29
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
30
|
+
import { z } from 'zod';
|
|
31
|
+
import axios from 'axios';
|
|
32
|
+
import FormData from 'form-data';
|
|
33
|
+
import { URL } from 'url';
|
|
34
|
+
import path from 'path';
|
|
35
|
+
|
|
36
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const API_BASE = 'https://api.schedpilot.com/developers/v1';
|
|
39
|
+
|
|
40
|
+
const TOKEN = process.env.SCHEDPILOT_TOKEN;
|
|
41
|
+
if (!TOKEN) {
|
|
42
|
+
process.stderr.write(
|
|
43
|
+
'[SchedPilot MCP] ERROR: SCHEDPILOT_TOKEN environment variable is not set.\n' +
|
|
44
|
+
'Set it to a personal API key (smm_...) or OAuth token (sp_tok_...).\n'
|
|
45
|
+
);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** Standard JSON headers for all requests */
|
|
52
|
+
function jsonHeaders() {
|
|
53
|
+
return {
|
|
54
|
+
Authorization: `Bearer ${TOKEN}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
Accept: 'application/json',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Wrap any value as a successful MCP text result */
|
|
61
|
+
function ok(data) {
|
|
62
|
+
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
63
|
+
return { content: [{ type: 'text', text }] };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Wrap an error string as a failed MCP result */
|
|
67
|
+
function fail(message) {
|
|
68
|
+
return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Call the API, return { ok, status, data } */
|
|
72
|
+
async function apiGet(path, params = {}) {
|
|
73
|
+
const qs = new URLSearchParams(
|
|
74
|
+
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined))
|
|
75
|
+
).toString();
|
|
76
|
+
const url = `${API_BASE}${path}${qs ? '?' + qs : ''}`;
|
|
77
|
+
const res = await axios.get(url, { headers: jsonHeaders(), validateStatus: () => true });
|
|
78
|
+
return { ok: res.status >= 200 && res.status < 300, status: res.status, data: res.data };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function apiPost(path, body) {
|
|
82
|
+
const res = await axios.post(`${API_BASE}${path}`, body, {
|
|
83
|
+
headers: jsonHeaders(),
|
|
84
|
+
validateStatus: () => true,
|
|
85
|
+
});
|
|
86
|
+
return { ok: res.status >= 200 && res.status < 300, status: res.status, data: res.data };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function apiDelete(path) {
|
|
90
|
+
const res = await axios.delete(`${API_BASE}${path}`, {
|
|
91
|
+
headers: jsonHeaders(),
|
|
92
|
+
validateStatus: () => true,
|
|
93
|
+
});
|
|
94
|
+
return { ok: res.status >= 200 && res.status < 300, status: res.status, data: res.data };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
// ── MCP Server ────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const server = new McpServer({
|
|
101
|
+
name: 'schedpilot',
|
|
102
|
+
version: '1.0.0',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
107
|
+
// Tool 1 — get_accounts
|
|
108
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
109
|
+
server.tool(
|
|
110
|
+
'get_accounts',
|
|
111
|
+
|
|
112
|
+
'Get all social media accounts connected to the SchedPilot account. ' +
|
|
113
|
+
'Returns each account\'s id, type (e.g. twitter, linkedin, instagram), display name, and username. ' +
|
|
114
|
+
'Always call this first to discover valid account IDs before creating posts.',
|
|
115
|
+
|
|
116
|
+
{}, // no inputs
|
|
117
|
+
|
|
118
|
+
async () => {
|
|
119
|
+
try {
|
|
120
|
+
const r = await apiGet('/accounts');
|
|
121
|
+
if (!r.ok) return fail(`${r.status} — ${JSON.stringify(r.data)}`);
|
|
122
|
+
return ok(r.data);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
return fail(err.message);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
// Tool 2 — list_posts
|
|
132
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
133
|
+
server.tool(
|
|
134
|
+
'list_posts',
|
|
135
|
+
|
|
136
|
+
'List social media posts with optional filters. ' +
|
|
137
|
+
'Returns post IDs, content, scheduled dates, platforms, and publish status.',
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
status: z.enum(['scheduled', 'posted', 'failed', 'draft', 'pending_approval', 'all'])
|
|
141
|
+
.default('all')
|
|
142
|
+
.describe('Filter by post status. Use "all" to see everything.'),
|
|
143
|
+
start_date: z.string().optional()
|
|
144
|
+
.describe('Only return posts on or after this date (YYYY-MM-DD).'),
|
|
145
|
+
end_date: z.string().optional()
|
|
146
|
+
.describe('Only return posts on or before this date (YYYY-MM-DD).'),
|
|
147
|
+
page: z.number().int().min(1).default(1)
|
|
148
|
+
.describe('Page number for pagination (starts at 1).'),
|
|
149
|
+
per_page: z.number().int().min(1).max(100).default(20)
|
|
150
|
+
.describe('Number of results per page (max 100).'),
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
async ({ status, start_date, end_date, page, per_page }) => {
|
|
154
|
+
try {
|
|
155
|
+
const r = await apiGet('/posts', { status, start_date, end_date, page, per_page });
|
|
156
|
+
if (!r.ok) return fail(`${r.status} — ${JSON.stringify(r.data)}`);
|
|
157
|
+
return ok(r.data);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
return fail(err.message);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
// Tool 3 — create_post
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
server.tool(
|
|
169
|
+
'create_post',
|
|
170
|
+
|
|
171
|
+
'Schedule a new social media post to one or more connected accounts. ' +
|
|
172
|
+
'Use get_accounts first to find valid account IDs and types. ' +
|
|
173
|
+
'Use upload_media first if you want to attach images or videos. ' +
|
|
174
|
+
'Returns the created post_id and scheduled details.',
|
|
175
|
+
|
|
176
|
+
{
|
|
177
|
+
content: z.string().min(1)
|
|
178
|
+
.describe('The text content of the post. For Twitter/X, keep it under 280 characters.'),
|
|
179
|
+
|
|
180
|
+
scheduled_date: z.string()
|
|
181
|
+
.describe('When to publish the post. Format: "YYYY-MM-DD HH:MM:SS" or ISO 8601. Example: "2024-06-15 14:30:00"'),
|
|
182
|
+
|
|
183
|
+
timezone: z.string().default('UTC')
|
|
184
|
+
.describe('Timezone for scheduled_date. Examples: "UTC", "America/New_York", "Europe/London". Defaults to UTC.'),
|
|
185
|
+
|
|
186
|
+
accounts: z.array(z.object({
|
|
187
|
+
id: z.number().int().describe('Account ID as returned by get_accounts.'),
|
|
188
|
+
type: z.string().describe('Account type exactly as returned by get_accounts (e.g. "twitter", "linkedin", "instagram", "linkedin_page").'),
|
|
189
|
+
})).min(1)
|
|
190
|
+
.describe('At least one account to post to. Get IDs from get_accounts.'),
|
|
191
|
+
|
|
192
|
+
image_ids: z.array(z.string()).optional()
|
|
193
|
+
.describe('Image media IDs to attach. Format: ["image-42", "image-43"]. Get IDs from upload_media.'),
|
|
194
|
+
|
|
195
|
+
video_ids: z.array(z.string()).optional()
|
|
196
|
+
.describe('Video media IDs to attach. Format: ["video-7"]. Get IDs from upload_media. Cannot combine with images.'),
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async ({ content, scheduled_date, timezone, accounts, image_ids, video_ids }) => {
|
|
200
|
+
try {
|
|
201
|
+
const body = { content, scheduled_date, timezone, accounts };
|
|
202
|
+
if (image_ids?.length) body.image_ids = image_ids;
|
|
203
|
+
if (video_ids?.length) body.video_ids = video_ids;
|
|
204
|
+
|
|
205
|
+
const r = await apiPost('/post', body);
|
|
206
|
+
if (!r.ok) return fail(`${r.status} — ${JSON.stringify(r.data)}`);
|
|
207
|
+
return ok(r.data);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return fail(err.message);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
|
+
// Tool 4 — delete_post
|
|
217
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
218
|
+
server.tool(
|
|
219
|
+
'delete_post',
|
|
220
|
+
|
|
221
|
+
'Cancel and permanently delete a scheduled post. ' +
|
|
222
|
+
'This will fail with a 409 error if the post has already been published on any platform — published posts cannot be deleted. ' +
|
|
223
|
+
'Get post IDs from list_posts or create_post.',
|
|
224
|
+
|
|
225
|
+
{
|
|
226
|
+
post_id: z.number().int().min(1)
|
|
227
|
+
.describe('The numeric ID of the post to delete.'),
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async ({ post_id }) => {
|
|
231
|
+
try {
|
|
232
|
+
const r = await apiDelete(`/posts/${post_id}`);
|
|
233
|
+
if (!r.ok) return fail(`${r.status} — ${JSON.stringify(r.data)}`);
|
|
234
|
+
return ok(r.data);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return fail(err.message);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
243
|
+
// Tool 5 — upload_media
|
|
244
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
245
|
+
server.tool(
|
|
246
|
+
'upload_media',
|
|
247
|
+
|
|
248
|
+
'Upload an image or video to SchedPilot from a public URL. ' +
|
|
249
|
+
'Returns a media_id string (e.g. "image-42" or "video-7") that you pass to create_post. ' +
|
|
250
|
+
'Supported images: JPG, PNG, GIF, WebP. Supported video: MP4, MOV, AVI, MKV. Max size: 5 GB.',
|
|
251
|
+
|
|
252
|
+
{
|
|
253
|
+
url: z.string().url()
|
|
254
|
+
.describe('A publicly accessible URL to the image or video file to upload.'),
|
|
255
|
+
filename: z.string().optional()
|
|
256
|
+
.describe('Optional filename hint (e.g. "banner.jpg"). Inferred from the URL if omitted.'),
|
|
257
|
+
},
|
|
258
|
+
|
|
259
|
+
async ({ url, filename }) => {
|
|
260
|
+
try {
|
|
261
|
+
// 1. Download the file from the provided URL
|
|
262
|
+
const download = await axios.get(url, {
|
|
263
|
+
responseType: 'arraybuffer',
|
|
264
|
+
timeout: 30_000,
|
|
265
|
+
validateStatus: () => true,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
if (download.status !== 200) {
|
|
269
|
+
return fail(`Could not download file — remote server responded with HTTP ${download.status}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const buffer = Buffer.from(download.data);
|
|
273
|
+
const contentType = download.headers['content-type'] || 'application/octet-stream';
|
|
274
|
+
|
|
275
|
+
// Derive a sensible filename from the URL when not supplied
|
|
276
|
+
let inferredName = filename;
|
|
277
|
+
if (!inferredName) {
|
|
278
|
+
try {
|
|
279
|
+
inferredName = path.basename(new URL(url).pathname) || 'upload.bin';
|
|
280
|
+
} catch {
|
|
281
|
+
inferredName = 'upload.bin';
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 2. Upload as multipart/form-data using the 'file' field the API expects
|
|
286
|
+
const form = new FormData();
|
|
287
|
+
form.append('file', buffer, { filename: inferredName, contentType });
|
|
288
|
+
|
|
289
|
+
const res = await axios.post(`${API_BASE}/media/upload`, form, {
|
|
290
|
+
headers: {
|
|
291
|
+
Authorization: `Bearer ${TOKEN}`,
|
|
292
|
+
...form.getHeaders(),
|
|
293
|
+
},
|
|
294
|
+
validateStatus: () => true,
|
|
295
|
+
maxContentLength: Infinity,
|
|
296
|
+
maxBodyLength: Infinity,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
if (!( res.status >= 200 && res.status < 300)) {
|
|
300
|
+
return fail(`Upload failed (${res.status}): ${JSON.stringify(res.data)}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Response: { media_id: "image-42", type: "image", mime_type: "image/jpeg" }
|
|
304
|
+
return ok(res.data);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
return fail(err.message);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
313
|
+
// Tool 6 — get_analytics
|
|
314
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
315
|
+
server.tool(
|
|
316
|
+
'get_analytics',
|
|
317
|
+
|
|
318
|
+
'Get performance analytics for a specific published post. ' +
|
|
319
|
+
'Returns impressions, reach, clicks, likes, comments, shares, and engagement rate ' +
|
|
320
|
+
'broken down by platform (LinkedIn, Twitter/X, etc.) plus aggregated totals. ' +
|
|
321
|
+
'Only works for posts with posted_status = "posted".',
|
|
322
|
+
|
|
323
|
+
{
|
|
324
|
+
post_id: z.number().int().min(1)
|
|
325
|
+
.describe('The numeric ID of the post to retrieve analytics for.'),
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
async ({ post_id }) => {
|
|
329
|
+
try {
|
|
330
|
+
const r = await apiGet(`/analytics/${post_id}`);
|
|
331
|
+
if (!r.ok) return fail(`${r.status} — ${JSON.stringify(r.data)}`);
|
|
332
|
+
return ok(r.data);
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return fail(err.message);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
// ── Connect & run ─────────────────────────────────────────────────────────────
|
|
341
|
+
|
|
342
|
+
const transport = new StdioServerTransport();
|
|
343
|
+
await server.connect(transport);
|