@postsider/mcp 0.0.0-bootstrap.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/.claude-plugin/plugin.json +29 -0
- package/.mcp.json +11 -0
- package/CHANGELOG.md +62 -0
- package/LICENSE +673 -0
- package/README.md +190 -0
- package/dist/client.js +268 -0
- package/dist/index.js +39 -0
- package/dist/post-body.js +29 -0
- package/dist/server.js +550 -0
- package/package.json +73 -0
- package/server.json +45 -0
- package/skills/postsider-workflow/SKILL.md +76 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostSider MCP server factory.
|
|
3
|
+
*
|
|
4
|
+
* Owns every tool registration so all transports (today: stdio; later: a remote
|
|
5
|
+
* Streamable HTTP server) share exactly one agent-facing surface. The factory
|
|
6
|
+
* takes an injected client and reads no environment variable, which keeps it
|
|
7
|
+
* importable from tests and from any entrypoint without side effects.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { buildCreatePostBody } from './post-body.js';
|
|
12
|
+
export const POSTSIDER_MCP_SERVER_NAME = 'postsider';
|
|
13
|
+
export const POSTSIDER_MCP_SERVER_VERSION = '1.0.0';
|
|
14
|
+
/** Wrap a tool handler so any error becomes an actionable MCP error result. */
|
|
15
|
+
function ok(data) {
|
|
16
|
+
return {
|
|
17
|
+
content: [
|
|
18
|
+
// Compact, not pretty-printed: every tool result is injected into the
|
|
19
|
+
// agent's context, and an indented body is roughly twice the tokens for
|
|
20
|
+
// no gain the agent can use.
|
|
21
|
+
{ type: 'text', text: JSON.stringify(data) },
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function fail(err) {
|
|
26
|
+
return {
|
|
27
|
+
isError: true,
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: 'text',
|
|
31
|
+
text: err instanceof Error ? err.message : String(err),
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const mediaItem = z
|
|
37
|
+
.object({
|
|
38
|
+
id: z.string().optional(),
|
|
39
|
+
path: z.string().min(1).describe('Media path/URL returned by an upload tool.'),
|
|
40
|
+
})
|
|
41
|
+
.passthrough();
|
|
42
|
+
/**
|
|
43
|
+
* ISO 8601 calendar date or date-time. Mirrors the API's `@IsDateString()`,
|
|
44
|
+
* which accepts both `2026-06-01` and `2026-06-01T00:00:00Z`; validating any
|
|
45
|
+
* harder here would reject payloads the API itself serves.
|
|
46
|
+
*/
|
|
47
|
+
const isoDate = z
|
|
48
|
+
.string()
|
|
49
|
+
.regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})?)?$/, 'Must be an ISO 8601 date (2026-06-01) or date-time (2026-06-01T00:00:00Z).')
|
|
50
|
+
.refine(isRealCalendarValue, {
|
|
51
|
+
message: 'Must be a date that exists, e.g. 2026-06-01 (month 1-12, a real day for that month, time 00:00:00-23:59:59).',
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* The regex above checks the shape; this checks that the value is a real moment.
|
|
55
|
+
* `2026-13-45` and `2026-02-30` both pass a shape test and would otherwise fail
|
|
56
|
+
* server-side with an opaque 400 instead of a local, fixable message.
|
|
57
|
+
*/
|
|
58
|
+
function isRealCalendarValue(value) {
|
|
59
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(value);
|
|
60
|
+
if (!match) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
64
|
+
const monthNumber = Number(month);
|
|
65
|
+
const dayNumber = Number(day);
|
|
66
|
+
if (monthNumber < 1 || monthNumber > 12) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
// Day 0 of the following month is the last day of this one.
|
|
70
|
+
const lastDay = new Date(Date.UTC(Number(year), monthNumber, 0)).getUTCDate();
|
|
71
|
+
if (dayNumber < 1 || dayNumber > lastDay) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (hour !== undefined && Number(hour) > 23) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
if (minute !== undefined && Number(minute) > 59) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (second !== undefined && Number(second) > 59) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Public HTTPS URL. The API additionally rejects internal addresses and
|
|
87
|
+
* non-media paths (its SSRF guard); those checks stay server-side.
|
|
88
|
+
*/
|
|
89
|
+
const httpsUrl = z
|
|
90
|
+
.string()
|
|
91
|
+
.url()
|
|
92
|
+
.refine((value) => new URL(value).protocol === 'https:', {
|
|
93
|
+
message: 'Must be a public HTTPS URL.',
|
|
94
|
+
});
|
|
95
|
+
/** Non-empty identifier, so a blank id fails locally instead of at the API. */
|
|
96
|
+
const nonEmptyId = z.string().min(1, 'Must be a non-empty id.');
|
|
97
|
+
/** Build the PostSider MCP server bound to `client`. */
|
|
98
|
+
export function createPostSiderMcpServer(client) {
|
|
99
|
+
const server = new McpServer({
|
|
100
|
+
name: POSTSIDER_MCP_SERVER_NAME,
|
|
101
|
+
version: POSTSIDER_MCP_SERVER_VERSION,
|
|
102
|
+
});
|
|
103
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
104
|
+
// Channels & scheduling helpers (read-only)
|
|
105
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
106
|
+
server.registerTool('postsider_list_channels', {
|
|
107
|
+
title: 'List channels',
|
|
108
|
+
description: 'List the social channels (integrations) connected to this PostSider organization. Returns each channel id, name and platform. Call this first to get the channel ids needed by postsider_create_post.',
|
|
109
|
+
inputSchema: {},
|
|
110
|
+
annotations: {
|
|
111
|
+
readOnlyHint: true,
|
|
112
|
+
destructiveHint: false,
|
|
113
|
+
idempotentHint: true,
|
|
114
|
+
openWorldHint: true,
|
|
115
|
+
},
|
|
116
|
+
}, async () => {
|
|
117
|
+
try {
|
|
118
|
+
return ok(await client.get('/integrations'));
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return fail(e);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
server.registerTool('postsider_get_agency_overview', {
|
|
125
|
+
title: 'Get agency overview',
|
|
126
|
+
description: 'Get an organization-wide operational overview for an agency: clients, channels, queued posts, drafts, published posts, errors, recent errors and pending approvals. Useful for morning checks and client reporting.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
days: z.number().int().positive().max(365).default(30).describe('Window for recent errors, in days.'),
|
|
129
|
+
},
|
|
130
|
+
annotations: {
|
|
131
|
+
readOnlyHint: true,
|
|
132
|
+
destructiveHint: false,
|
|
133
|
+
idempotentHint: true,
|
|
134
|
+
openWorldHint: true,
|
|
135
|
+
},
|
|
136
|
+
}, async ({ days }) => {
|
|
137
|
+
try {
|
|
138
|
+
return ok(await client.get('/overview', { days }));
|
|
139
|
+
}
|
|
140
|
+
catch (e) {
|
|
141
|
+
return fail(e);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
server.registerTool('postsider_get_customer_report', {
|
|
145
|
+
title: 'Get customer report',
|
|
146
|
+
description: 'Get a customer-scoped agency report with channels, queued, draft, published, error and pending approval counts.',
|
|
147
|
+
inputSchema: {
|
|
148
|
+
customerId: nonEmptyId.describe('Customer id from postsider_list_groups.'),
|
|
149
|
+
days: z.number().int().positive().max(365).default(30).describe('Window for recent errors, in days.'),
|
|
150
|
+
},
|
|
151
|
+
annotations: {
|
|
152
|
+
readOnlyHint: true,
|
|
153
|
+
destructiveHint: false,
|
|
154
|
+
idempotentHint: true,
|
|
155
|
+
openWorldHint: true,
|
|
156
|
+
},
|
|
157
|
+
}, async ({ customerId, days }) => {
|
|
158
|
+
try {
|
|
159
|
+
return ok(await client.get(`/customers/${encodeURIComponent(customerId)}/report`, { days }));
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return fail(e);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
server.registerTool('postsider_list_groups', {
|
|
166
|
+
title: 'List channel groups',
|
|
167
|
+
description: 'List configured channel groups. In PostSider a group is a customer, so an id returned here is the customerId expected by postsider_get_customer_report and by the customer filter of postsider_list_posts.',
|
|
168
|
+
inputSchema: {},
|
|
169
|
+
annotations: {
|
|
170
|
+
readOnlyHint: true,
|
|
171
|
+
destructiveHint: false,
|
|
172
|
+
idempotentHint: true,
|
|
173
|
+
openWorldHint: true,
|
|
174
|
+
},
|
|
175
|
+
}, async () => {
|
|
176
|
+
try {
|
|
177
|
+
return ok(await client.get('/groups'));
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
return fail(e);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
server.registerTool('postsider_find_slot', {
|
|
184
|
+
title: 'Find next free time slot',
|
|
185
|
+
description: 'Return the next free scheduling date-time (UTC) for a channel, based on its configured posting queue. Use the returned value as the `date` for postsider_create_post when scheduling into the queue.',
|
|
186
|
+
inputSchema: {
|
|
187
|
+
channelId: nonEmptyId.describe('Channel (integration) id, from postsider_list_channels.'),
|
|
188
|
+
},
|
|
189
|
+
annotations: {
|
|
190
|
+
readOnlyHint: true,
|
|
191
|
+
destructiveHint: false,
|
|
192
|
+
idempotentHint: true,
|
|
193
|
+
openWorldHint: true,
|
|
194
|
+
},
|
|
195
|
+
}, async ({ channelId }) => {
|
|
196
|
+
try {
|
|
197
|
+
return ok(await client.get(`/find-slot/${encodeURIComponent(channelId)}`));
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
return fail(e);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
server.registerTool('postsider_list_posts', {
|
|
204
|
+
title: 'List posts',
|
|
205
|
+
description: 'List posts scheduled or published within a date range (UTC). Useful for reviewing the content calendar before scheduling more.',
|
|
206
|
+
inputSchema: {
|
|
207
|
+
startDate: isoDate.describe('Range start, ISO 8601 (e.g. 2026-06-01T00:00:00Z).'),
|
|
208
|
+
endDate: isoDate.describe('Range end, ISO 8601 (e.g. 2026-06-30T23:59:59Z).'),
|
|
209
|
+
customer: nonEmptyId
|
|
210
|
+
.optional()
|
|
211
|
+
.describe('Optional customer id to filter by.'),
|
|
212
|
+
},
|
|
213
|
+
annotations: {
|
|
214
|
+
readOnlyHint: true,
|
|
215
|
+
destructiveHint: false,
|
|
216
|
+
idempotentHint: true,
|
|
217
|
+
openWorldHint: true,
|
|
218
|
+
},
|
|
219
|
+
}, async ({ startDate, endDate, customer }) => {
|
|
220
|
+
try {
|
|
221
|
+
return ok(await client.get('/posts', { startDate, endDate, customer }));
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
return fail(e);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
server.registerTool('postsider_get_post_missing_fields', {
|
|
228
|
+
title: 'Check post for missing fields',
|
|
229
|
+
description: 'Return per-channel validation problems / missing required fields for a post, so they can be fixed before publishing.',
|
|
230
|
+
inputSchema: {
|
|
231
|
+
postId: nonEmptyId.describe('Post id.'),
|
|
232
|
+
},
|
|
233
|
+
annotations: {
|
|
234
|
+
readOnlyHint: true,
|
|
235
|
+
destructiveHint: false,
|
|
236
|
+
idempotentHint: true,
|
|
237
|
+
openWorldHint: true,
|
|
238
|
+
},
|
|
239
|
+
}, async ({ postId }) => {
|
|
240
|
+
try {
|
|
241
|
+
return ok(await client.get(`/posts/${encodeURIComponent(postId)}/missing`));
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
return fail(e);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
server.registerTool('postsider_get_post', {
|
|
248
|
+
title: 'Get post details',
|
|
249
|
+
description: 'Get the full organization-scoped post group, including current state, scheduled time, media, channel and publish error. Use this to inspect the result of an asynchronous create or publish operation.',
|
|
250
|
+
inputSchema: {
|
|
251
|
+
postId: nonEmptyId.describe('Post id returned by postsider_create_post.'),
|
|
252
|
+
},
|
|
253
|
+
annotations: {
|
|
254
|
+
readOnlyHint: true,
|
|
255
|
+
destructiveHint: false,
|
|
256
|
+
idempotentHint: true,
|
|
257
|
+
openWorldHint: true,
|
|
258
|
+
},
|
|
259
|
+
}, async ({ postId }) => {
|
|
260
|
+
try {
|
|
261
|
+
return ok(await client.get(`/posts/${encodeURIComponent(postId)}`));
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
return fail(e);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
server.registerTool('postsider_get_post_analytics', {
|
|
268
|
+
title: 'Get post analytics',
|
|
269
|
+
description: 'Get performance analytics for a single post over the last N days (where the provider supports it).',
|
|
270
|
+
inputSchema: {
|
|
271
|
+
postId: nonEmptyId.describe('Post id.'),
|
|
272
|
+
days: z
|
|
273
|
+
.number()
|
|
274
|
+
.int()
|
|
275
|
+
.positive()
|
|
276
|
+
.default(7)
|
|
277
|
+
.describe('Look-back window in days (default 7).'),
|
|
278
|
+
},
|
|
279
|
+
annotations: {
|
|
280
|
+
readOnlyHint: true,
|
|
281
|
+
destructiveHint: false,
|
|
282
|
+
idempotentHint: true,
|
|
283
|
+
openWorldHint: true,
|
|
284
|
+
},
|
|
285
|
+
}, async ({ postId, days }) => {
|
|
286
|
+
try {
|
|
287
|
+
return ok(await client.get(`/analytics/post/${encodeURIComponent(postId)}`, {
|
|
288
|
+
date: days,
|
|
289
|
+
}));
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
return fail(e);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
server.registerTool('postsider_get_channel_analytics', {
|
|
296
|
+
title: 'Get channel analytics',
|
|
297
|
+
description: 'Get account-level analytics for a connected channel (where the provider supports it).',
|
|
298
|
+
inputSchema: {
|
|
299
|
+
channelId: nonEmptyId.describe('Channel (integration) id, from postsider_list_channels.'),
|
|
300
|
+
date: z
|
|
301
|
+
.string()
|
|
302
|
+
.optional()
|
|
303
|
+
.describe('Provider-specific date/range parameter, if required.'),
|
|
304
|
+
},
|
|
305
|
+
annotations: {
|
|
306
|
+
readOnlyHint: true,
|
|
307
|
+
destructiveHint: false,
|
|
308
|
+
idempotentHint: true,
|
|
309
|
+
openWorldHint: true,
|
|
310
|
+
},
|
|
311
|
+
}, async ({ channelId, date }) => {
|
|
312
|
+
try {
|
|
313
|
+
return ok(await client.get(`/analytics/${encodeURIComponent(channelId)}`, { date }));
|
|
314
|
+
}
|
|
315
|
+
catch (e) {
|
|
316
|
+
return fail(e);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
server.registerTool('postsider_get_notifications', {
|
|
320
|
+
title: 'Get notifications',
|
|
321
|
+
description: 'List recent notifications for the organization (e.g. publish failures, channels needing reconnection).',
|
|
322
|
+
inputSchema: {},
|
|
323
|
+
annotations: {
|
|
324
|
+
readOnlyHint: true,
|
|
325
|
+
destructiveHint: false,
|
|
326
|
+
idempotentHint: true,
|
|
327
|
+
openWorldHint: true,
|
|
328
|
+
},
|
|
329
|
+
}, async () => {
|
|
330
|
+
try {
|
|
331
|
+
return ok(await client.get('/notifications'));
|
|
332
|
+
}
|
|
333
|
+
catch (e) {
|
|
334
|
+
return fail(e);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
338
|
+
// Emergency Pause (kill switch)
|
|
339
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
340
|
+
server.registerTool('postsider_pause_publishing', {
|
|
341
|
+
title: 'Pause all publishing',
|
|
342
|
+
description: 'Immediately halt ALL publishing for this organization (kill switch): no `now` or `schedule` posts can be created, and queued posts are parked to HELD instead of going out. Use when something is wrong, for example a PR crisis, a post on the wrong channel, or a runaway automation loop. Resume is human-only (owner, dashboard); this cannot be undone via the API.',
|
|
343
|
+
inputSchema: {
|
|
344
|
+
reason: z
|
|
345
|
+
.string()
|
|
346
|
+
.optional()
|
|
347
|
+
.describe('Optional reason, recorded in the audit trail and shown to the team.'),
|
|
348
|
+
},
|
|
349
|
+
annotations: {
|
|
350
|
+
readOnlyHint: false,
|
|
351
|
+
destructiveHint: true,
|
|
352
|
+
idempotentHint: true,
|
|
353
|
+
openWorldHint: true,
|
|
354
|
+
},
|
|
355
|
+
}, async ({ reason }) => {
|
|
356
|
+
try {
|
|
357
|
+
return ok(await client.post('/publishing/pause', { reason }));
|
|
358
|
+
}
|
|
359
|
+
catch (e) {
|
|
360
|
+
return fail(e);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
server.registerTool('postsider_get_publishing_state', {
|
|
364
|
+
title: 'Get publishing state',
|
|
365
|
+
description: 'Check whether publishing is active or paused for this organization. Useful before scheduling (a paused org rejects new posts with publishing_paused), and to confirm a kill switch is on.',
|
|
366
|
+
inputSchema: {},
|
|
367
|
+
annotations: {
|
|
368
|
+
readOnlyHint: true,
|
|
369
|
+
destructiveHint: false,
|
|
370
|
+
idempotentHint: true,
|
|
371
|
+
openWorldHint: true,
|
|
372
|
+
},
|
|
373
|
+
}, async () => {
|
|
374
|
+
try {
|
|
375
|
+
return ok(await client.get('/publishing/state'));
|
|
376
|
+
}
|
|
377
|
+
catch (e) {
|
|
378
|
+
return fail(e);
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
382
|
+
// Media
|
|
383
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
384
|
+
server.registerTool('postsider_upload_media_from_url', {
|
|
385
|
+
title: 'Upload media from URL',
|
|
386
|
+
description: 'Download an image or video from a public URL and store it in the PostSider media library. Returns a media object; pass it (or its array) as `images` to postsider_create_post.',
|
|
387
|
+
inputSchema: {
|
|
388
|
+
url: httpsUrl.describe('Public HTTPS URL of the image or video to import.'),
|
|
389
|
+
},
|
|
390
|
+
annotations: {
|
|
391
|
+
readOnlyHint: false,
|
|
392
|
+
destructiveHint: false,
|
|
393
|
+
idempotentHint: false,
|
|
394
|
+
openWorldHint: true,
|
|
395
|
+
},
|
|
396
|
+
}, async ({ url }) => {
|
|
397
|
+
try {
|
|
398
|
+
return ok(await client.post('/upload-from-url', { url }));
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
return fail(e);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
405
|
+
// Posting (write)
|
|
406
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
407
|
+
server.registerTool('postsider_create_post', {
|
|
408
|
+
title: 'Create / schedule / publish a post',
|
|
409
|
+
description: 'Create a post across one or more channels. `type`: "schedule" books it for `date`; "now" publishes immediately; "draft" saves without publishing. Get channel ids from postsider_list_channels and a free slot from postsider_find_slot. Attach media via postsider_upload_media_from_url first, then pass the returned media objects as `images`.',
|
|
410
|
+
inputSchema: {
|
|
411
|
+
type: z
|
|
412
|
+
.enum(['draft', 'schedule', 'now'])
|
|
413
|
+
.default('schedule')
|
|
414
|
+
.describe('draft = save only; schedule = book for `date`; now = publish immediately.'),
|
|
415
|
+
date: isoDate.describe('Required publish date, ISO 8601 UTC (e.g. 2026-07-01T10:00:00Z). For "now" use the current time; drafts still require a date in the current API contract.'),
|
|
416
|
+
shortLink: z
|
|
417
|
+
.boolean()
|
|
418
|
+
.default(false)
|
|
419
|
+
.describe('Whether to shorten links in the content.'),
|
|
420
|
+
posts: z
|
|
421
|
+
.array(z.object({
|
|
422
|
+
channelId: nonEmptyId.describe('Channel (integration) id to publish to.'),
|
|
423
|
+
content: z.string().describe('The post text/caption.'),
|
|
424
|
+
firstComment: z
|
|
425
|
+
.string()
|
|
426
|
+
.optional()
|
|
427
|
+
.describe('Optional first comment posted right after (where supported).'),
|
|
428
|
+
images: z
|
|
429
|
+
.array(mediaItem)
|
|
430
|
+
.optional()
|
|
431
|
+
.describe('Optional media objects from postsider_upload_media_from_url.'),
|
|
432
|
+
settings: z
|
|
433
|
+
.record(z.any())
|
|
434
|
+
.optional()
|
|
435
|
+
.describe('Provider-specific settings. Required fields: X: who_can_reply_post (everyone, following, mentionedUsers, subscribers, or verified). Instagram: post_type (post or story). YouTube: title and type (public, private, or unlisted). TikTok: privacy_level (PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, or SELF_ONLY); duet, stitch, comment, brand_content_toggle, and brand_organic_toggle (booleans); autoAddMusic (yes or no); and content_posting_method (DIRECT_POST or UPLOAD). Other providers may impose additional requirements; preserve settings returned by postsider_get_post when replacing an existing post.'),
|
|
436
|
+
}))
|
|
437
|
+
.min(1)
|
|
438
|
+
.describe('One entry per channel to publish to.'),
|
|
439
|
+
tags: z
|
|
440
|
+
.array(z.object({ value: z.string(), label: z.string() }))
|
|
441
|
+
.optional()
|
|
442
|
+
.describe('Optional tags.'),
|
|
443
|
+
idempotencyKey: z
|
|
444
|
+
.string()
|
|
445
|
+
.min(1)
|
|
446
|
+
.max(255)
|
|
447
|
+
.optional()
|
|
448
|
+
.describe('Stable key for safe retries. Reusing it with the same request returns the original result without creating duplicates.'),
|
|
449
|
+
},
|
|
450
|
+
annotations: {
|
|
451
|
+
readOnlyHint: false,
|
|
452
|
+
destructiveHint: false,
|
|
453
|
+
idempotentHint: false,
|
|
454
|
+
openWorldHint: true,
|
|
455
|
+
},
|
|
456
|
+
}, async ({ type, date, shortLink, posts, tags, idempotencyKey }) => {
|
|
457
|
+
try {
|
|
458
|
+
const body = buildCreatePostBody({ type, date, shortLink, posts, tags });
|
|
459
|
+
return ok(await client.post('/posts', body, idempotencyKey));
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
return fail(e);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
server.registerTool('postsider_update_post_status', {
|
|
466
|
+
title: 'Update post status',
|
|
467
|
+
description: 'Change the status of an existing post (e.g. move between draft and queue).',
|
|
468
|
+
inputSchema: {
|
|
469
|
+
postId: nonEmptyId.describe('Post id.'),
|
|
470
|
+
status: z
|
|
471
|
+
.enum(['draft', 'schedule'])
|
|
472
|
+
.describe('New status value: draft or schedule.'),
|
|
473
|
+
},
|
|
474
|
+
annotations: {
|
|
475
|
+
readOnlyHint: false,
|
|
476
|
+
destructiveHint: false,
|
|
477
|
+
idempotentHint: true,
|
|
478
|
+
openWorldHint: true,
|
|
479
|
+
},
|
|
480
|
+
}, async ({ postId, status }) => {
|
|
481
|
+
try {
|
|
482
|
+
return ok(await client.put(`/posts/${encodeURIComponent(postId)}/status`, { status }));
|
|
483
|
+
}
|
|
484
|
+
catch (e) {
|
|
485
|
+
return fail(e);
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
server.registerTool('postsider_delete_post', {
|
|
489
|
+
title: 'Delete a post',
|
|
490
|
+
description: 'Permanently delete a post and every other channel version of it. The id may be any post in a group: PostSider stores a multi-channel post as one group, and a single id deletes the whole group. Read the post first (postsider_get_post) and confirm the full set of channel versions with the user before calling this.',
|
|
491
|
+
inputSchema: {
|
|
492
|
+
postId: nonEmptyId.describe('Post id to delete.'),
|
|
493
|
+
},
|
|
494
|
+
annotations: {
|
|
495
|
+
readOnlyHint: false,
|
|
496
|
+
destructiveHint: true,
|
|
497
|
+
idempotentHint: true,
|
|
498
|
+
openWorldHint: true,
|
|
499
|
+
},
|
|
500
|
+
}, async ({ postId }) => {
|
|
501
|
+
try {
|
|
502
|
+
await client.del(`/posts/${encodeURIComponent(postId)}`);
|
|
503
|
+
return ok({ deleted: true, postId });
|
|
504
|
+
}
|
|
505
|
+
catch (e) {
|
|
506
|
+
return fail(e);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
server.registerTool('postsider_request_approval', {
|
|
510
|
+
title: 'Send a draft for approval',
|
|
511
|
+
description: 'Push a draft post into the human approval queue for review, instead of publishing or scheduling it directly. The post must already exist as a draft (see postsider_create_post with type "draft"). Approval is optional in PostSider, and most posts can also be scheduled directly without ever going through this.',
|
|
512
|
+
inputSchema: {
|
|
513
|
+
postId: nonEmptyId.describe('Draft post id to submit for approval.'),
|
|
514
|
+
},
|
|
515
|
+
annotations: {
|
|
516
|
+
readOnlyHint: false,
|
|
517
|
+
destructiveHint: false,
|
|
518
|
+
idempotentHint: true,
|
|
519
|
+
openWorldHint: true,
|
|
520
|
+
},
|
|
521
|
+
}, async ({ postId }) => {
|
|
522
|
+
try {
|
|
523
|
+
return ok(await client.post(`/posts/${encodeURIComponent(postId)}/request-approval`));
|
|
524
|
+
}
|
|
525
|
+
catch (e) {
|
|
526
|
+
return fail(e);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
server.registerTool('postsider_get_approval_status', {
|
|
530
|
+
title: 'Get a post\'s approval status',
|
|
531
|
+
description: 'Check whether a post submitted via postsider_request_approval has been approved, rejected (with the reviewer\'s note, if any), or is still pending. Returns status "NONE" if the post was never sent for approval.',
|
|
532
|
+
inputSchema: {
|
|
533
|
+
postId: nonEmptyId.describe('Post id to check.'),
|
|
534
|
+
},
|
|
535
|
+
annotations: {
|
|
536
|
+
readOnlyHint: true,
|
|
537
|
+
destructiveHint: false,
|
|
538
|
+
idempotentHint: true,
|
|
539
|
+
openWorldHint: true,
|
|
540
|
+
},
|
|
541
|
+
}, async ({ postId }) => {
|
|
542
|
+
try {
|
|
543
|
+
return ok(await client.get(`/posts/${encodeURIComponent(postId)}/approval`));
|
|
544
|
+
}
|
|
545
|
+
catch (e) {
|
|
546
|
+
return fail(e);
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
return server;
|
|
550
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@postsider/mcp",
|
|
3
|
+
"version": "0.0.0-bootstrap.0",
|
|
4
|
+
"mcpName": "io.github.lumizone/postsider",
|
|
5
|
+
"description": "PostSider MCP server - gives AI agents access to the PostSider social media platform over its public API (list channels, review the calendar, create drafts, request approval, analytics, media).",
|
|
6
|
+
"license": "AGPL-3.0",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"postsider-mcp": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"skills",
|
|
14
|
+
".claude-plugin",
|
|
15
|
+
".mcp.json",
|
|
16
|
+
"server.json",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"CHANGELOG.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
|
|
23
|
+
"build": "tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/index.js',0o755)\"",
|
|
24
|
+
"prepack": "npm run clean && npm run build",
|
|
25
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
26
|
+
"typecheck:tests": "tsc -p tsconfig.test.json --noEmit",
|
|
27
|
+
"test": "NODE_OPTIONS=--experimental-vm-modules jest -c jest.config.cjs --runInBand",
|
|
28
|
+
"smoke:tarball": "node scripts/tarball-smoke.mjs",
|
|
29
|
+
"validate:registry": "node scripts/validate-registry.mjs",
|
|
30
|
+
"version:gate": "node scripts/version-gate.mjs",
|
|
31
|
+
"start": "node dist/index.js",
|
|
32
|
+
"dev": "tsc -p tsconfig.json --watch"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://postsider.com",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/lumizone/postsider.git",
|
|
38
|
+
"directory": "apps/mcp"
|
|
39
|
+
},
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/lumizone/postsider/issues"
|
|
42
|
+
},
|
|
43
|
+
"author": "Lumi Zone Łukasz Blania <lukasz@postsider.com>",
|
|
44
|
+
"keywords": [
|
|
45
|
+
"mcp",
|
|
46
|
+
"model-context-protocol",
|
|
47
|
+
"claude",
|
|
48
|
+
"claude-code",
|
|
49
|
+
"ai-agents",
|
|
50
|
+
"social-media",
|
|
51
|
+
"scheduling",
|
|
52
|
+
"postsider"
|
|
53
|
+
],
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public",
|
|
56
|
+
"provenance": false
|
|
57
|
+
},
|
|
58
|
+
"dependencies": {
|
|
59
|
+
"@modelcontextprotocol/sdk": "^1.22.0",
|
|
60
|
+
"zod": "^3.25.76"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@jest/globals": "29.7.0",
|
|
64
|
+
"@types/node": "^22.10.0",
|
|
65
|
+
"ajv": "^8.17.1",
|
|
66
|
+
"jest": "29.7.0",
|
|
67
|
+
"ts-jest": "^29.1.0",
|
|
68
|
+
"typescript": "^5.5.0"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=20.17.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/server.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.lumizone/postsider",
|
|
4
|
+
"title": "PostSider",
|
|
5
|
+
"description": "Social media calendar for AI agents: read channels, create drafts, request approval.",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"websiteUrl": "https://postsider.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"url": "https://github.com/lumizone/postsider",
|
|
10
|
+
"source": "github",
|
|
11
|
+
"subfolder": "apps/mcp",
|
|
12
|
+
"id": "1334967121"
|
|
13
|
+
},
|
|
14
|
+
"packages": [
|
|
15
|
+
{
|
|
16
|
+
"registryType": "npm",
|
|
17
|
+
"identifier": "@postsider/mcp",
|
|
18
|
+
"version": "1.0.0",
|
|
19
|
+
"transport": {
|
|
20
|
+
"type": "stdio"
|
|
21
|
+
},
|
|
22
|
+
"environmentVariables": [
|
|
23
|
+
{
|
|
24
|
+
"name": "POSTSIDER_API_KEY",
|
|
25
|
+
"description": "Organization API key from PostSider under Settings -> API.",
|
|
26
|
+
"isRequired": true,
|
|
27
|
+
"isSecret": true
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "POSTSIDER_API_URL",
|
|
31
|
+
"description": "Instance base URL. Defaults to https://api.postsider.com and must be HTTPS for non-local hosts.",
|
|
32
|
+
"isRequired": false,
|
|
33
|
+
"isSecret": false
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
],
|
|
38
|
+
"_meta": {
|
|
39
|
+
"io.modelcontextprotocol.registry/publisher-provided": {
|
|
40
|
+
"longDescription": "PostSider MCP lets Claude Code, Codex, Cursor and other MCP clients inspect a real social media calendar, prepare native channel versions, create drafts, request approval and read publishing results through PostSider's public API.",
|
|
41
|
+
"documentation": "https://docs.postsider.com/cloud/mcp",
|
|
42
|
+
"toolCount": 19
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|