postproxy-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/LICENSE +21 -0
- package/README.md +635 -0
- package/dist/api/client.d.ts +71 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +432 -0
- package/dist/api/client.js.map +1 -0
- package/dist/auth/credentials.d.ts +19 -0
- package/dist/auth/credentials.d.ts.map +1 -0
- package/dist/auth/credentials.js +40 -0
- package/dist/auth/credentials.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +162 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +220 -0
- package/dist/server.js.map +1 -0
- package/dist/setup-cli.d.ts +6 -0
- package/dist/setup-cli.d.ts.map +1 -0
- package/dist/setup-cli.js +10 -0
- package/dist/setup-cli.js.map +1 -0
- package/dist/setup.d.ts +8 -0
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +143 -0
- package/dist/setup.js.map +1 -0
- package/dist/tools/accounts.d.ts +11 -0
- package/dist/tools/accounts.d.ts.map +1 -0
- package/dist/tools/accounts.js +53 -0
- package/dist/tools/accounts.js.map +1 -0
- package/dist/tools/auth.d.ts +11 -0
- package/dist/tools/auth.d.ts.map +1 -0
- package/dist/tools/auth.js +35 -0
- package/dist/tools/auth.js.map +1 -0
- package/dist/tools/history.d.ts +13 -0
- package/dist/tools/history.d.ts.map +1 -0
- package/dist/tools/history.js +79 -0
- package/dist/tools/history.js.map +1 -0
- package/dist/tools/post.d.ts +44 -0
- package/dist/tools/post.d.ts.map +1 -0
- package/dist/tools/post.js +251 -0
- package/dist/tools/post.js.map +1 -0
- package/dist/tools/profiles.d.ts +11 -0
- package/dist/tools/profiles.d.ts.map +1 -0
- package/dist/tools/profiles.js +52 -0
- package/dist/tools/profiles.js.map +1 -0
- package/dist/types/index.d.ts +147 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +5 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/errors.d.ts +21 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +33 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/idempotency.d.ts +8 -0
- package/dist/utils/idempotency.d.ts.map +1 -0
- package/dist/utils/idempotency.js +23 -0
- package/dist/utils/idempotency.js.map +1 -0
- package/dist/utils/logger.d.ts +20 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +68 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/validation.d.ts +555 -0
- package/dist/utils/validation.d.ts.map +1 -0
- package/dist/utils/validation.js +145 -0
- package/dist/utils/validation.js.map +1 -0
- package/package.json +39 -0
- package/src/api/client.ts +497 -0
- package/src/auth/credentials.ts +43 -0
- package/src/index.ts +57 -0
- package/src/server.ts +235 -0
- package/src/setup-cli.ts +11 -0
- package/src/setup.ts +187 -0
- package/src/tools/auth.ts +45 -0
- package/src/tools/history.ts +89 -0
- package/src/tools/post.ts +338 -0
- package/src/tools/profiles.ts +69 -0
- package/src/types/index.ts +161 -0
- package/src/utils/errors.ts +38 -0
- package/src/utils/idempotency.ts +31 -0
- package/src/utils/logger.ts +75 -0
- package/src/utils/validation.ts +171 -0
- package/tsconfig.json +19 -0
- package/worker/index.ts +901 -0
- package/wrangler.toml +11 -0
package/worker/index.ts
ADDED
|
@@ -0,0 +1,901 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostProxy MCP - Cloudflare Worker Entry Point
|
|
3
|
+
*
|
|
4
|
+
* This worker provides the same MCP functionality as the local stdio version,
|
|
5
|
+
* but runs on Cloudflare Workers for remote access.
|
|
6
|
+
*
|
|
7
|
+
* API key is passed via X-PostProxy-API-Key header from the client.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { WorkerEntrypoint } from "cloudflare:workers";
|
|
11
|
+
|
|
12
|
+
interface Env {
|
|
13
|
+
POSTPROXY_BASE_URL: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProfileGroup {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
profiles_count: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Profile {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
platform: string;
|
|
26
|
+
profile_group_id: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PlatformOutcome {
|
|
30
|
+
platform: string;
|
|
31
|
+
status: "pending" | "processing" | "published" | "failed" | "deleted";
|
|
32
|
+
url?: string;
|
|
33
|
+
post_id?: string;
|
|
34
|
+
error?: string | null;
|
|
35
|
+
attempted_at: string | null;
|
|
36
|
+
insights?: any;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Post {
|
|
40
|
+
id: string;
|
|
41
|
+
body?: string;
|
|
42
|
+
content?: string;
|
|
43
|
+
status: "draft" | "pending" | "processing" | "processed" | "scheduled";
|
|
44
|
+
draft: boolean;
|
|
45
|
+
scheduled_at: string | null;
|
|
46
|
+
created_at: string;
|
|
47
|
+
platforms: PlatformOutcome[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default class PostProxyMCP extends WorkerEntrypoint<Env> {
|
|
51
|
+
private apiKey: string | null = null;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get API key from request context
|
|
55
|
+
*/
|
|
56
|
+
private getApiKey(): string {
|
|
57
|
+
if (!this.apiKey) {
|
|
58
|
+
throw new Error("API key not configured. Pass X-PostProxy-API-Key header.");
|
|
59
|
+
}
|
|
60
|
+
return this.apiKey;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Make an HTTP request to the PostProxy API
|
|
65
|
+
*/
|
|
66
|
+
private async apiRequest<T>(
|
|
67
|
+
method: string,
|
|
68
|
+
path: string,
|
|
69
|
+
body?: any,
|
|
70
|
+
extraHeaders?: Record<string, string>
|
|
71
|
+
): Promise<T> {
|
|
72
|
+
const baseUrl = this.env.POSTPROXY_BASE_URL.replace(/\/$/, "");
|
|
73
|
+
const url = `${baseUrl}${path}`;
|
|
74
|
+
|
|
75
|
+
const headers: Record<string, string> = {
|
|
76
|
+
Authorization: `Bearer ${this.getApiKey()}`,
|
|
77
|
+
"Content-Type": "application/json",
|
|
78
|
+
"Accept": "application/json",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
if (extraHeaders) {
|
|
82
|
+
Object.assign(headers, extraHeaders);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const options: RequestInit = {
|
|
86
|
+
method,
|
|
87
|
+
headers,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (body && (method === "POST" || method === "PUT" || method === "PATCH")) {
|
|
91
|
+
options.body = JSON.stringify(body);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const response = await fetch(url, options);
|
|
95
|
+
|
|
96
|
+
if (!response.ok) {
|
|
97
|
+
let errorMessage = `API request failed with status ${response.status}`;
|
|
98
|
+
try {
|
|
99
|
+
const errorBody = await response.json() as any;
|
|
100
|
+
if (Array.isArray(errorBody.errors)) {
|
|
101
|
+
errorMessage = errorBody.errors.join("; ");
|
|
102
|
+
} else if (errorBody.message) {
|
|
103
|
+
errorMessage = errorBody.message;
|
|
104
|
+
} else if (errorBody.error) {
|
|
105
|
+
errorMessage = typeof errorBody.error === "string" ? errorBody.error : errorMessage;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
errorMessage = response.statusText || errorMessage;
|
|
109
|
+
}
|
|
110
|
+
throw new Error(errorMessage);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const contentType = response.headers.get("content-type");
|
|
114
|
+
if (contentType && contentType.includes("application/json")) {
|
|
115
|
+
return (await response.json()) as T;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {} as T;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Extract array from API response
|
|
123
|
+
*/
|
|
124
|
+
private extractArray<T>(response: any): T[] {
|
|
125
|
+
if (Array.isArray(response)) {
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
if (response && typeof response === "object" && Array.isArray(response.data)) {
|
|
129
|
+
return response.data;
|
|
130
|
+
}
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get all profiles
|
|
136
|
+
*/
|
|
137
|
+
private async getAllProfiles(): Promise<Profile[]> {
|
|
138
|
+
const groupsResponse = await this.apiRequest<any>("GET", "/profile_groups/");
|
|
139
|
+
const groups = this.extractArray<ProfileGroup>(groupsResponse);
|
|
140
|
+
|
|
141
|
+
const allProfiles: Profile[] = [];
|
|
142
|
+
for (const group of groups) {
|
|
143
|
+
const profilesResponse = await this.apiRequest<any>("GET", `/profiles?group_id=${group.id}`);
|
|
144
|
+
const profiles = this.extractArray<Profile>(profilesResponse);
|
|
145
|
+
allProfiles.push(...profiles);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return allProfiles;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get MIME type based on file extension
|
|
153
|
+
*/
|
|
154
|
+
private getMimeType(filename: string): string {
|
|
155
|
+
const ext = filename.toLowerCase().split(".").pop();
|
|
156
|
+
const mimeTypes: Record<string, string> = {
|
|
157
|
+
jpg: "image/jpeg",
|
|
158
|
+
jpeg: "image/jpeg",
|
|
159
|
+
png: "image/png",
|
|
160
|
+
gif: "image/gif",
|
|
161
|
+
webp: "image/webp",
|
|
162
|
+
mp4: "video/mp4",
|
|
163
|
+
mov: "video/quicktime",
|
|
164
|
+
avi: "video/x-msvideo",
|
|
165
|
+
webm: "video/webm",
|
|
166
|
+
};
|
|
167
|
+
return mimeTypes[ext || ""] || "application/octet-stream";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create post with file uploads using multipart/form-data
|
|
172
|
+
*/
|
|
173
|
+
private async createPostWithFiles(
|
|
174
|
+
content: string,
|
|
175
|
+
platformNames: string[],
|
|
176
|
+
mediaFiles: Array<{ filename: string; data: string; content_type?: string }>,
|
|
177
|
+
schedule?: string,
|
|
178
|
+
draft?: boolean,
|
|
179
|
+
platformParams?: Record<string, Record<string, any>>,
|
|
180
|
+
idempotencyKey?: string
|
|
181
|
+
): Promise<any> {
|
|
182
|
+
const baseUrl = this.env.POSTPROXY_BASE_URL.replace(/\/$/, "");
|
|
183
|
+
const url = `${baseUrl}/posts`;
|
|
184
|
+
const formData = new FormData();
|
|
185
|
+
|
|
186
|
+
// Add post body
|
|
187
|
+
formData.append("post[body]", content);
|
|
188
|
+
|
|
189
|
+
// Add scheduled_at if provided
|
|
190
|
+
if (schedule) {
|
|
191
|
+
formData.append("post[scheduled_at]", schedule);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Add draft if provided
|
|
195
|
+
if (draft !== undefined) {
|
|
196
|
+
formData.append("post[draft]", String(draft));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Add profiles (platform names)
|
|
200
|
+
for (const profile of platformNames) {
|
|
201
|
+
formData.append("profiles[]", profile);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Add media files from base64
|
|
205
|
+
for (const file of mediaFiles) {
|
|
206
|
+
try {
|
|
207
|
+
// Decode base64 to binary
|
|
208
|
+
const binaryString = atob(file.data);
|
|
209
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
210
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
211
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const mimeType = file.content_type || this.getMimeType(file.filename);
|
|
215
|
+
const blob = new Blob([bytes], { type: mimeType });
|
|
216
|
+
formData.append("media[]", blob, file.filename);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new Error(`Failed to decode file ${file.filename}: ${(error as Error).message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Add platform-specific parameters as JSON
|
|
223
|
+
if (platformParams && Object.keys(platformParams).length > 0) {
|
|
224
|
+
formData.append("platforms", JSON.stringify(platformParams));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Build headers
|
|
228
|
+
const headers: Record<string, string> = {
|
|
229
|
+
Authorization: `Bearer ${this.getApiKey()}`,
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
if (idempotencyKey) {
|
|
233
|
+
headers["Idempotency-Key"] = idempotencyKey;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const response = await fetch(url, {
|
|
237
|
+
method: "POST",
|
|
238
|
+
headers,
|
|
239
|
+
body: formData,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
let errorMessage = `API request failed with status ${response.status}`;
|
|
244
|
+
try {
|
|
245
|
+
const errorBody = await response.json() as any;
|
|
246
|
+
if (Array.isArray(errorBody.errors)) {
|
|
247
|
+
errorMessage = errorBody.errors.join("; ");
|
|
248
|
+
} else if (errorBody.message) {
|
|
249
|
+
errorMessage = errorBody.message;
|
|
250
|
+
} else if (errorBody.error) {
|
|
251
|
+
errorMessage = typeof errorBody.error === "string" ? errorBody.error : errorMessage;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
errorMessage = response.statusText || errorMessage;
|
|
255
|
+
}
|
|
256
|
+
throw new Error(errorMessage);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return await response.json();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Generate idempotency key from post data
|
|
264
|
+
*/
|
|
265
|
+
private async generateIdempotencyKey(
|
|
266
|
+
content: string,
|
|
267
|
+
targets: string[],
|
|
268
|
+
schedule?: string
|
|
269
|
+
): Promise<string> {
|
|
270
|
+
const normalizedContent = content.trim();
|
|
271
|
+
const normalizedTargets = [...targets].sort();
|
|
272
|
+
const normalizedSchedule = schedule || "";
|
|
273
|
+
|
|
274
|
+
const data = JSON.stringify({
|
|
275
|
+
content: normalizedContent,
|
|
276
|
+
targets: normalizedTargets,
|
|
277
|
+
schedule: normalizedSchedule,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Use Web Crypto API (available in Workers)
|
|
281
|
+
const encoder = new TextEncoder();
|
|
282
|
+
const dataBuffer = encoder.encode(data);
|
|
283
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", dataBuffer);
|
|
284
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
285
|
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Determine overall status from post and platform statuses
|
|
290
|
+
*/
|
|
291
|
+
private determineOverallStatus(
|
|
292
|
+
post: Post
|
|
293
|
+
): "pending" | "processing" | "complete" | "failed" | "draft" {
|
|
294
|
+
if (post.status === "draft" || post.draft === true) {
|
|
295
|
+
return "draft";
|
|
296
|
+
}
|
|
297
|
+
if (post.status === "scheduled") {
|
|
298
|
+
return "pending";
|
|
299
|
+
}
|
|
300
|
+
if (post.status === "processing") {
|
|
301
|
+
return "processing";
|
|
302
|
+
}
|
|
303
|
+
if (post.status === "processed") {
|
|
304
|
+
const platforms = post.platforms || [];
|
|
305
|
+
if (platforms.length === 0) {
|
|
306
|
+
return "pending";
|
|
307
|
+
}
|
|
308
|
+
const allPublished = platforms.every((p) => p.status === "published");
|
|
309
|
+
const allFailed = platforms.every((p) => p.status === "failed");
|
|
310
|
+
const anyPending = platforms.some((p) => p.status === "pending" || p.status === "processing");
|
|
311
|
+
|
|
312
|
+
if (anyPending) {
|
|
313
|
+
return "processing";
|
|
314
|
+
} else if (allPublished) {
|
|
315
|
+
return "complete";
|
|
316
|
+
} else if (allFailed) {
|
|
317
|
+
return "failed";
|
|
318
|
+
} else {
|
|
319
|
+
return "complete";
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (post.status === "pending") {
|
|
323
|
+
return "pending";
|
|
324
|
+
}
|
|
325
|
+
return "pending";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Check authentication status, API configuration, and workspace information
|
|
330
|
+
* @return {Promise<string>} Authentication status and workspace info as JSON
|
|
331
|
+
*/
|
|
332
|
+
async authStatus(): Promise<string> {
|
|
333
|
+
const hasApiKey = !!this.apiKey;
|
|
334
|
+
const result: {
|
|
335
|
+
authenticated: boolean;
|
|
336
|
+
base_url: string;
|
|
337
|
+
profile_groups_count?: number;
|
|
338
|
+
} = {
|
|
339
|
+
authenticated: hasApiKey,
|
|
340
|
+
base_url: this.env.POSTPROXY_BASE_URL,
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (hasApiKey) {
|
|
344
|
+
try {
|
|
345
|
+
const groupsResponse = await this.apiRequest<any>("GET", "/profile_groups/");
|
|
346
|
+
const groups = this.extractArray<ProfileGroup>(groupsResponse);
|
|
347
|
+
result.profile_groups_count = groups.length;
|
|
348
|
+
} catch {
|
|
349
|
+
// Ignore errors, just return without count
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return JSON.stringify(result, null, 2);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* List all available social media profiles (targets) for posting
|
|
358
|
+
* @return {Promise<string>} List of available profiles as JSON
|
|
359
|
+
*/
|
|
360
|
+
async profilesList(): Promise<string> {
|
|
361
|
+
this.getApiKey(); // Validate API key is present
|
|
362
|
+
|
|
363
|
+
const profiles = await this.getAllProfiles();
|
|
364
|
+
const targets = profiles.map((profile) => ({
|
|
365
|
+
id: profile.id,
|
|
366
|
+
name: profile.name,
|
|
367
|
+
platform: profile.platform,
|
|
368
|
+
profile_group_id: profile.profile_group_id,
|
|
369
|
+
}));
|
|
370
|
+
|
|
371
|
+
return JSON.stringify({ targets }, null, 2);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Publish a post to specified targets
|
|
376
|
+
* @param content {string} Post content text
|
|
377
|
+
* @param targets {string} Comma-separated list of target profile IDs
|
|
378
|
+
* @param schedule {string} Optional ISO 8601 scheduled time
|
|
379
|
+
* @param media {string} Optional comma-separated list of media URLs
|
|
380
|
+
* @param idempotency_key {string} Optional idempotency key for deduplication
|
|
381
|
+
* @param require_confirmation {boolean} If true, return summary without publishing
|
|
382
|
+
* @param draft {boolean} If true, creates a draft post that won't publish automatically
|
|
383
|
+
* @param platforms {string} Optional JSON string of platform-specific parameters
|
|
384
|
+
* @param media_files {string} Optional JSON array of file objects with {filename, data (base64), content_type?}
|
|
385
|
+
* @return {Promise<string>} Post creation result as JSON
|
|
386
|
+
*/
|
|
387
|
+
async postPublish(
|
|
388
|
+
content: string,
|
|
389
|
+
targets: string,
|
|
390
|
+
schedule?: string,
|
|
391
|
+
media?: string,
|
|
392
|
+
idempotency_key?: string,
|
|
393
|
+
require_confirmation?: boolean,
|
|
394
|
+
draft?: boolean,
|
|
395
|
+
platforms?: string,
|
|
396
|
+
media_files?: string
|
|
397
|
+
): Promise<string> {
|
|
398
|
+
this.getApiKey(); // Validate API key is present
|
|
399
|
+
|
|
400
|
+
// Parse comma-separated values
|
|
401
|
+
const targetIds = targets.split(",").map((t) => t.trim()).filter(Boolean);
|
|
402
|
+
const mediaUrls = media ? media.split(",").map((m) => m.trim()).filter(Boolean) : [];
|
|
403
|
+
|
|
404
|
+
// Parse media_files JSON if provided
|
|
405
|
+
let mediaFilesArray: Array<{ filename: string; data: string; content_type?: string }> = [];
|
|
406
|
+
if (media_files) {
|
|
407
|
+
try {
|
|
408
|
+
mediaFilesArray = JSON.parse(media_files);
|
|
409
|
+
if (!Array.isArray(mediaFilesArray)) {
|
|
410
|
+
throw new Error("media_files must be an array");
|
|
411
|
+
}
|
|
412
|
+
for (const file of mediaFilesArray) {
|
|
413
|
+
if (!file.filename || !file.data) {
|
|
414
|
+
throw new Error("Each media file must have 'filename' and 'data' (base64) properties");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} catch (e: any) {
|
|
418
|
+
if (e.message.includes("media_files")) {
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
throw new Error("Invalid media_files parameter: must be valid JSON array");
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Parse platforms JSON if provided
|
|
426
|
+
let platformParams: Record<string, Record<string, any>> | undefined;
|
|
427
|
+
if (platforms) {
|
|
428
|
+
try {
|
|
429
|
+
platformParams = JSON.parse(platforms);
|
|
430
|
+
} catch {
|
|
431
|
+
throw new Error("Invalid platforms parameter: must be valid JSON");
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Validate input
|
|
436
|
+
if (!content || content.trim() === "") {
|
|
437
|
+
throw new Error("Content cannot be empty");
|
|
438
|
+
}
|
|
439
|
+
if (targetIds.length === 0) {
|
|
440
|
+
throw new Error("At least one target is required");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// If require_confirmation, return summary without publishing
|
|
444
|
+
if (require_confirmation) {
|
|
445
|
+
return JSON.stringify(
|
|
446
|
+
{
|
|
447
|
+
summary: {
|
|
448
|
+
targets: targetIds,
|
|
449
|
+
content_preview: content.substring(0, 100) + (content.length > 100 ? "..." : ""),
|
|
450
|
+
media_count: mediaUrls.length + mediaFilesArray.length,
|
|
451
|
+
media_files_count: mediaFilesArray.length,
|
|
452
|
+
schedule_time: schedule,
|
|
453
|
+
draft: draft || false,
|
|
454
|
+
platforms: platformParams || {},
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
null,
|
|
458
|
+
2
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Get profiles to convert target IDs to platform names
|
|
463
|
+
const profiles = await this.getAllProfiles();
|
|
464
|
+
const profilesMap = new Map<string, Profile>();
|
|
465
|
+
for (const profile of profiles) {
|
|
466
|
+
profilesMap.set(profile.id, profile);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Convert target IDs to platform names
|
|
470
|
+
const platformNames: string[] = [];
|
|
471
|
+
for (const targetId of targetIds) {
|
|
472
|
+
const profile = profilesMap.get(targetId);
|
|
473
|
+
if (!profile) {
|
|
474
|
+
throw new Error(`Target ${targetId} not found`);
|
|
475
|
+
}
|
|
476
|
+
platformNames.push(profile.platform);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Validate platforms keys match target platforms
|
|
480
|
+
if (platformParams) {
|
|
481
|
+
const invalidPlatforms = Object.keys(platformParams).filter(
|
|
482
|
+
(key) => !platformNames.includes(key)
|
|
483
|
+
);
|
|
484
|
+
if (invalidPlatforms.length > 0) {
|
|
485
|
+
throw new Error(
|
|
486
|
+
`Platform parameters specified for platforms not in targets: ${invalidPlatforms.join(", ")}. Available platforms: ${platformNames.join(", ")}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Generate idempotency key if not provided
|
|
492
|
+
const finalIdempotencyKey =
|
|
493
|
+
idempotency_key || (await this.generateIdempotencyKey(content, targetIds, schedule));
|
|
494
|
+
|
|
495
|
+
let response: any;
|
|
496
|
+
|
|
497
|
+
// Use multipart upload if media files are provided
|
|
498
|
+
if (mediaFilesArray.length > 0) {
|
|
499
|
+
response = await this.createPostWithFiles(
|
|
500
|
+
content,
|
|
501
|
+
platformNames,
|
|
502
|
+
mediaFilesArray,
|
|
503
|
+
schedule,
|
|
504
|
+
draft,
|
|
505
|
+
platformParams,
|
|
506
|
+
finalIdempotencyKey
|
|
507
|
+
);
|
|
508
|
+
} else {
|
|
509
|
+
// Create post with JSON (URLs only)
|
|
510
|
+
const apiPayload: any = {
|
|
511
|
+
post: {
|
|
512
|
+
body: content,
|
|
513
|
+
},
|
|
514
|
+
profiles: platformNames,
|
|
515
|
+
media: mediaUrls,
|
|
516
|
+
};
|
|
517
|
+
|
|
518
|
+
if (schedule) {
|
|
519
|
+
apiPayload.post.scheduled_at = schedule;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (draft !== undefined) {
|
|
523
|
+
apiPayload.post.draft = draft;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (platformParams && Object.keys(platformParams).length > 0) {
|
|
527
|
+
apiPayload.platforms = platformParams;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const extraHeaders: Record<string, string> = {
|
|
531
|
+
"Idempotency-Key": finalIdempotencyKey,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
response = await this.apiRequest<any>("POST", "/posts", apiPayload, extraHeaders);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Check if draft was requested but ignored
|
|
538
|
+
const wasDraftRequested = draft === true;
|
|
539
|
+
const isDraftInResponse = Boolean(response.draft) === true;
|
|
540
|
+
const wasProcessedImmediately = response.status === "processed" && wasDraftRequested;
|
|
541
|
+
const draftIgnored = wasDraftRequested && (!isDraftInResponse || wasProcessedImmediately);
|
|
542
|
+
|
|
543
|
+
const responseData: any = {
|
|
544
|
+
job_id: response.id,
|
|
545
|
+
status: response.status,
|
|
546
|
+
draft: response.draft,
|
|
547
|
+
scheduled_at: response.scheduled_at,
|
|
548
|
+
created_at: response.created_at,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
if (draftIgnored) {
|
|
552
|
+
responseData.warning = "Warning: Draft was requested but API returned draft: false. The post may have been processed immediately.";
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return JSON.stringify(responseData, null, 2);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Get status of a published post by job ID
|
|
560
|
+
* @param job_id {string} Job ID from post.publish response
|
|
561
|
+
* @return {Promise<string>} Post status as JSON
|
|
562
|
+
*/
|
|
563
|
+
async postStatus(job_id: string): Promise<string> {
|
|
564
|
+
if (!job_id) {
|
|
565
|
+
throw new Error("job_id is required");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const postDetails = await this.apiRequest<Post>("GET", `/posts/${job_id}`);
|
|
569
|
+
|
|
570
|
+
const platforms = (postDetails.platforms || []).map((platform) => ({
|
|
571
|
+
platform: platform.platform,
|
|
572
|
+
status: platform.status,
|
|
573
|
+
url: platform.url,
|
|
574
|
+
post_id: platform.post_id,
|
|
575
|
+
error: platform.error || null,
|
|
576
|
+
attempted_at: platform.attempted_at,
|
|
577
|
+
insights: platform.insights,
|
|
578
|
+
}));
|
|
579
|
+
|
|
580
|
+
const overallStatus = this.determineOverallStatus(postDetails);
|
|
581
|
+
|
|
582
|
+
return JSON.stringify(
|
|
583
|
+
{
|
|
584
|
+
job_id: job_id,
|
|
585
|
+
overall_status: overallStatus,
|
|
586
|
+
draft: postDetails.draft || false,
|
|
587
|
+
status: postDetails.status,
|
|
588
|
+
platforms,
|
|
589
|
+
},
|
|
590
|
+
null,
|
|
591
|
+
2
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Publish a draft post
|
|
597
|
+
* @param job_id {string} Job ID of the draft post to publish
|
|
598
|
+
* @return {Promise<string>} Published post result as JSON
|
|
599
|
+
*/
|
|
600
|
+
async postPublishDraft(job_id: string): Promise<string> {
|
|
601
|
+
if (!job_id) {
|
|
602
|
+
throw new Error("job_id is required");
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// First check if the post exists and is a draft
|
|
606
|
+
const postDetails = await this.apiRequest<Post>("GET", `/posts/${job_id}`);
|
|
607
|
+
|
|
608
|
+
if (!postDetails.draft && postDetails.status !== "draft") {
|
|
609
|
+
throw new Error(`Post ${job_id} is not a draft and cannot be published using this endpoint`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Publish the draft post
|
|
613
|
+
const publishedPost = await this.apiRequest<Post>("POST", `/posts/${job_id}/publish`);
|
|
614
|
+
|
|
615
|
+
return JSON.stringify(
|
|
616
|
+
{
|
|
617
|
+
job_id: publishedPost.id,
|
|
618
|
+
status: publishedPost.status,
|
|
619
|
+
draft: publishedPost.draft,
|
|
620
|
+
scheduled_at: publishedPost.scheduled_at,
|
|
621
|
+
created_at: publishedPost.created_at,
|
|
622
|
+
message: "Draft post published successfully",
|
|
623
|
+
},
|
|
624
|
+
null,
|
|
625
|
+
2
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Delete a post by job ID
|
|
631
|
+
* @param job_id {string} Job ID to delete
|
|
632
|
+
* @return {Promise<string>} Deletion confirmation as JSON
|
|
633
|
+
*/
|
|
634
|
+
async postDelete(job_id: string): Promise<string> {
|
|
635
|
+
if (!job_id) {
|
|
636
|
+
throw new Error("job_id is required");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
await this.apiRequest<void>("DELETE", `/posts/${job_id}`);
|
|
640
|
+
|
|
641
|
+
return JSON.stringify(
|
|
642
|
+
{
|
|
643
|
+
job_id: job_id,
|
|
644
|
+
deleted: true,
|
|
645
|
+
},
|
|
646
|
+
null,
|
|
647
|
+
2
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* List recent post jobs
|
|
653
|
+
* @param limit {number} Maximum number of jobs to return (default: 10)
|
|
654
|
+
* @return {Promise<string>} List of recent jobs as JSON
|
|
655
|
+
*/
|
|
656
|
+
async historyList(limit?: number): Promise<string> {
|
|
657
|
+
const effectiveLimit = limit || 10;
|
|
658
|
+
|
|
659
|
+
const response = await this.apiRequest<any>("GET", `/posts?per_page=${effectiveLimit}`);
|
|
660
|
+
const posts = this.extractArray<Post>(response);
|
|
661
|
+
|
|
662
|
+
const jobs = posts.map((post) => {
|
|
663
|
+
const overallStatus = this.determineOverallStatus(post);
|
|
664
|
+
const content = post.body || post.content || "";
|
|
665
|
+
|
|
666
|
+
return {
|
|
667
|
+
job_id: post.id,
|
|
668
|
+
content_preview: content.substring(0, 100) + (content.length > 100 ? "..." : ""),
|
|
669
|
+
created_at: post.created_at,
|
|
670
|
+
overall_status: overallStatus,
|
|
671
|
+
draft: post.draft || false,
|
|
672
|
+
platforms_count: post.platforms?.length || 0,
|
|
673
|
+
};
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
return JSON.stringify({ jobs }, null, 2);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* MCP tool definitions
|
|
681
|
+
*/
|
|
682
|
+
private getTools() {
|
|
683
|
+
return [
|
|
684
|
+
{
|
|
685
|
+
name: "authStatus",
|
|
686
|
+
description: "Check authentication status, API configuration, and workspace information",
|
|
687
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
688
|
+
},
|
|
689
|
+
{
|
|
690
|
+
name: "profilesList",
|
|
691
|
+
description: "List all available social media profiles (targets) for posting",
|
|
692
|
+
inputSchema: { type: "object", properties: {}, required: [] },
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
name: "postPublish",
|
|
696
|
+
description: "Publish a post to specified targets",
|
|
697
|
+
inputSchema: {
|
|
698
|
+
type: "object",
|
|
699
|
+
properties: {
|
|
700
|
+
content: { type: "string", description: "Post content text" },
|
|
701
|
+
targets: { type: "string", description: "Comma-separated list of target profile IDs" },
|
|
702
|
+
schedule: { type: "string", description: "Optional ISO 8601 scheduled time" },
|
|
703
|
+
media: { type: "string", description: "Optional comma-separated list of media URLs" },
|
|
704
|
+
idempotency_key: { type: "string", description: "Optional idempotency key for deduplication" },
|
|
705
|
+
require_confirmation: { type: "boolean", description: "If true, return summary without publishing" },
|
|
706
|
+
draft: { type: "boolean", description: "If true, creates a draft post" },
|
|
707
|
+
platforms: { type: "string", description: "Optional JSON string of platform-specific parameters" },
|
|
708
|
+
media_files: { type: "string", description: "Optional JSON array of file objects for direct upload. Each object must have 'filename' and 'data' (base64-encoded file content), optionally 'content_type'. Example: [{\"filename\":\"photo.jpg\",\"data\":\"base64...\"}]" },
|
|
709
|
+
},
|
|
710
|
+
required: ["content", "targets"],
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
name: "postStatus",
|
|
715
|
+
description: "Get status of a published post by job ID",
|
|
716
|
+
inputSchema: {
|
|
717
|
+
type: "object",
|
|
718
|
+
properties: {
|
|
719
|
+
job_id: { type: "string", description: "Job ID from post.publish response" },
|
|
720
|
+
},
|
|
721
|
+
required: ["job_id"],
|
|
722
|
+
},
|
|
723
|
+
},
|
|
724
|
+
{
|
|
725
|
+
name: "postPublishDraft",
|
|
726
|
+
description: "Publish a draft post",
|
|
727
|
+
inputSchema: {
|
|
728
|
+
type: "object",
|
|
729
|
+
properties: {
|
|
730
|
+
job_id: { type: "string", description: "Job ID of the draft post to publish" },
|
|
731
|
+
},
|
|
732
|
+
required: ["job_id"],
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
name: "postDelete",
|
|
737
|
+
description: "Delete a post by job ID",
|
|
738
|
+
inputSchema: {
|
|
739
|
+
type: "object",
|
|
740
|
+
properties: {
|
|
741
|
+
job_id: { type: "string", description: "Job ID to delete" },
|
|
742
|
+
},
|
|
743
|
+
required: ["job_id"],
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: "historyList",
|
|
748
|
+
description: "List recent post jobs",
|
|
749
|
+
inputSchema: {
|
|
750
|
+
type: "object",
|
|
751
|
+
properties: {
|
|
752
|
+
limit: { type: "number", description: "Maximum number of jobs to return (default: 10)" },
|
|
753
|
+
},
|
|
754
|
+
required: [],
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
];
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Handle MCP JSON-RPC request
|
|
762
|
+
*/
|
|
763
|
+
private async handleMcpRequest(body: any): Promise<any> {
|
|
764
|
+
const { jsonrpc, method, params, id } = body;
|
|
765
|
+
|
|
766
|
+
if (jsonrpc !== "2.0") {
|
|
767
|
+
return { jsonrpc: "2.0", error: { code: -32600, message: "Invalid Request" }, id };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
switch (method) {
|
|
771
|
+
case "initialize":
|
|
772
|
+
return {
|
|
773
|
+
jsonrpc: "2.0",
|
|
774
|
+
result: {
|
|
775
|
+
protocolVersion: "2024-11-05",
|
|
776
|
+
capabilities: { tools: {} },
|
|
777
|
+
serverInfo: { name: "postproxy-mcp", version: "0.1.0" },
|
|
778
|
+
},
|
|
779
|
+
id,
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
case "notifications/initialized":
|
|
783
|
+
return null; // No response for notifications
|
|
784
|
+
|
|
785
|
+
case "tools/list":
|
|
786
|
+
return {
|
|
787
|
+
jsonrpc: "2.0",
|
|
788
|
+
result: { tools: this.getTools() },
|
|
789
|
+
id,
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
case "tools/call": {
|
|
793
|
+
const { name, arguments: args } = params || {};
|
|
794
|
+
try {
|
|
795
|
+
let result: string;
|
|
796
|
+
switch (name) {
|
|
797
|
+
case "authStatus":
|
|
798
|
+
result = await this.authStatus();
|
|
799
|
+
break;
|
|
800
|
+
case "profilesList":
|
|
801
|
+
result = await this.profilesList();
|
|
802
|
+
break;
|
|
803
|
+
case "postPublish":
|
|
804
|
+
result = await this.postPublish(
|
|
805
|
+
args.content,
|
|
806
|
+
args.targets,
|
|
807
|
+
args.schedule,
|
|
808
|
+
args.media,
|
|
809
|
+
args.idempotency_key,
|
|
810
|
+
args.require_confirmation,
|
|
811
|
+
args.draft,
|
|
812
|
+
args.platforms,
|
|
813
|
+
args.media_files
|
|
814
|
+
);
|
|
815
|
+
break;
|
|
816
|
+
case "postStatus":
|
|
817
|
+
result = await this.postStatus(args.job_id);
|
|
818
|
+
break;
|
|
819
|
+
case "postPublishDraft":
|
|
820
|
+
result = await this.postPublishDraft(args.job_id);
|
|
821
|
+
break;
|
|
822
|
+
case "postDelete":
|
|
823
|
+
result = await this.postDelete(args.job_id);
|
|
824
|
+
break;
|
|
825
|
+
case "historyList":
|
|
826
|
+
result = await this.historyList(args?.limit);
|
|
827
|
+
break;
|
|
828
|
+
default:
|
|
829
|
+
return {
|
|
830
|
+
jsonrpc: "2.0",
|
|
831
|
+
error: { code: -32601, message: `Unknown tool: ${name}` },
|
|
832
|
+
id,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
jsonrpc: "2.0",
|
|
837
|
+
result: { content: [{ type: "text", text: result }] },
|
|
838
|
+
id,
|
|
839
|
+
};
|
|
840
|
+
} catch (e: any) {
|
|
841
|
+
return {
|
|
842
|
+
jsonrpc: "2.0",
|
|
843
|
+
result: { content: [{ type: "text", text: `Error: ${e.message}` }], isError: true },
|
|
844
|
+
id,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
default:
|
|
850
|
+
return {
|
|
851
|
+
jsonrpc: "2.0",
|
|
852
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
853
|
+
id,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Handle incoming requests
|
|
860
|
+
*/
|
|
861
|
+
async fetch(request: Request): Promise<Response> {
|
|
862
|
+
const url = new URL(request.url);
|
|
863
|
+
|
|
864
|
+
// Only handle /mcp path
|
|
865
|
+
if (url.pathname !== "/mcp") {
|
|
866
|
+
return new Response("Not Found", { status: 404 });
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// Extract API key from header or query parameter
|
|
870
|
+
this.apiKey = request.headers.get("X-PostProxy-API-Key") || url.searchParams.get("api_key");
|
|
871
|
+
|
|
872
|
+
// Handle POST requests (MCP JSON-RPC)
|
|
873
|
+
if (request.method === "POST") {
|
|
874
|
+
try {
|
|
875
|
+
const body = await request.json();
|
|
876
|
+
const result = await this.handleMcpRequest(body);
|
|
877
|
+
|
|
878
|
+
// Notifications don't get a response
|
|
879
|
+
if (result === null) {
|
|
880
|
+
return new Response(null, { status: 204 });
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return Response.json(result);
|
|
884
|
+
} catch (e: any) {
|
|
885
|
+
return Response.json({
|
|
886
|
+
jsonrpc: "2.0",
|
|
887
|
+
error: { code: -32700, message: "Parse error" },
|
|
888
|
+
id: null,
|
|
889
|
+
}, { status: 400 });
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// GET request - return server info
|
|
894
|
+
return Response.json({
|
|
895
|
+
name: "postproxy-mcp",
|
|
896
|
+
version: "0.1.0",
|
|
897
|
+
description: "MCP server for PostProxy API",
|
|
898
|
+
tools: this.getTools().map(t => t.name),
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
}
|