@followr/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 +56 -0
- package/dist/bin/followr-mcp.js +2790 -0
- package/dist/bin/followr-mcp.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,2790 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// bin/followr-mcp.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// ../shared/dist/errors.js
|
|
8
|
+
var FollowrApiError = class _FollowrApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
url;
|
|
11
|
+
body;
|
|
12
|
+
validationErrors;
|
|
13
|
+
name = "FollowrApiError";
|
|
14
|
+
constructor(message, status, url, body, validationErrors) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.url = url;
|
|
18
|
+
this.body = body;
|
|
19
|
+
this.validationErrors = validationErrors;
|
|
20
|
+
}
|
|
21
|
+
static async fromResponse(response, url) {
|
|
22
|
+
let body;
|
|
23
|
+
let validationErrors;
|
|
24
|
+
let message = `${response.status} ${response.statusText}`;
|
|
25
|
+
try {
|
|
26
|
+
body = await response.json();
|
|
27
|
+
if (typeof body === "object" && body !== null) {
|
|
28
|
+
const b = body;
|
|
29
|
+
if (typeof b["message"] === "string") {
|
|
30
|
+
message = b["message"];
|
|
31
|
+
}
|
|
32
|
+
if (typeof b["errors"] === "object" && b["errors"] !== null) {
|
|
33
|
+
validationErrors = b["errors"];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
}
|
|
38
|
+
return new _FollowrApiError(message, response.status, url, body, validationErrors);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
var SPANISH_TO_ENGLISH = {
|
|
42
|
+
"No autenticado": "Not authenticated. Token is missing or expired.",
|
|
43
|
+
"No autorizado": "Not authorized for this resource.",
|
|
44
|
+
"El recurso no existe": "Resource not found.",
|
|
45
|
+
"Token expirado": "Token expired. Generate a new one in Followr Settings > API Keys.",
|
|
46
|
+
"Sin cr\xE9ditos": "Insufficient credits in your Followr plan.",
|
|
47
|
+
"Solicitud inv\xE1lida": "Invalid request body."
|
|
48
|
+
};
|
|
49
|
+
function translateError(message) {
|
|
50
|
+
return SPANISH_TO_ENGLISH[message] ?? message;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ../shared/dist/followr.js
|
|
54
|
+
var DEFAULT_BASE_URL = "https://api.followr.ai";
|
|
55
|
+
function buildQueryString(query) {
|
|
56
|
+
if (!query)
|
|
57
|
+
return "";
|
|
58
|
+
const params = new URLSearchParams();
|
|
59
|
+
for (const [key, value] of Object.entries(query)) {
|
|
60
|
+
if (value === void 0)
|
|
61
|
+
continue;
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
params.append(key, value.join(","));
|
|
64
|
+
} else {
|
|
65
|
+
params.append(key, String(value));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const qs = params.toString();
|
|
69
|
+
return qs ? `?${qs}` : "";
|
|
70
|
+
}
|
|
71
|
+
var FollowrClient = class {
|
|
72
|
+
baseUrl;
|
|
73
|
+
token;
|
|
74
|
+
fetchImpl;
|
|
75
|
+
constructor(options) {
|
|
76
|
+
this.token = options.token;
|
|
77
|
+
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
78
|
+
const f = options.fetchImpl ?? globalThis.fetch;
|
|
79
|
+
this.fetchImpl = (input, init) => f(input, init);
|
|
80
|
+
}
|
|
81
|
+
async request(method, path, options) {
|
|
82
|
+
const url = `${this.baseUrl}${path}${buildQueryString(options?.query)}`;
|
|
83
|
+
const headers = {
|
|
84
|
+
Authorization: `Bearer ${this.token}`,
|
|
85
|
+
Accept: "application/json"
|
|
86
|
+
};
|
|
87
|
+
let bodyInit;
|
|
88
|
+
if (options?.body !== void 0) {
|
|
89
|
+
headers["Content-Type"] = "application/json";
|
|
90
|
+
bodyInit = JSON.stringify(options.body);
|
|
91
|
+
}
|
|
92
|
+
const response = await this.fetchImpl(url, { method, headers, body: bodyInit });
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
const err = await FollowrApiError.fromResponse(response, url);
|
|
95
|
+
err.message = translateError(err.message);
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
if (response.status === 204) {
|
|
99
|
+
return void 0;
|
|
100
|
+
}
|
|
101
|
+
return await response.json();
|
|
102
|
+
}
|
|
103
|
+
// ──────────────────────────────────────────────────────────
|
|
104
|
+
// Auth / user
|
|
105
|
+
// ──────────────────────────────────────────────────────────
|
|
106
|
+
/** GET /api/users/me. Returns the user that owns the current token. */
|
|
107
|
+
async getMe() {
|
|
108
|
+
const result = await this.request("GET", "/api/users/me");
|
|
109
|
+
return result.data;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* GET /api/users?filter[companies.id]=X. Lists users with access to a workspace.
|
|
113
|
+
* Filter is `companies.id` (relation), per session 6 convention check.
|
|
114
|
+
*/
|
|
115
|
+
async listUsersInCompany(companyId, options) {
|
|
116
|
+
const result = await this.request("GET", "/api/users", {
|
|
117
|
+
query: {
|
|
118
|
+
"filter[companies.id]": companyId,
|
|
119
|
+
"page[size]": options?.pageSize ?? 30
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
return result.data;
|
|
123
|
+
}
|
|
124
|
+
// ──────────────────────────────────────────────────────────
|
|
125
|
+
// Companies
|
|
126
|
+
// ──────────────────────────────────────────────────────────
|
|
127
|
+
async listCompanies(options) {
|
|
128
|
+
const result = await this.request("GET", "/api/companies", {
|
|
129
|
+
query: {
|
|
130
|
+
"page[size]": options?.pageSize ?? 30,
|
|
131
|
+
"page[number]": options?.pageNumber ?? 1,
|
|
132
|
+
...options?.query ? { "filter[name]": options.query } : {}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return result.data;
|
|
136
|
+
}
|
|
137
|
+
async getCompany(companyId) {
|
|
138
|
+
const result = await this.request("GET", `/api/companies/${companyId}`);
|
|
139
|
+
return result.data;
|
|
140
|
+
}
|
|
141
|
+
/** PUT /api/companies/{id} with merged field. Used for webhook_posts_url, ai_keys, menu_visibility, etc. */
|
|
142
|
+
async updateCompany(companyId, patch) {
|
|
143
|
+
const result = await this.request("PUT", `/api/companies/${companyId}`, {
|
|
144
|
+
body: patch
|
|
145
|
+
});
|
|
146
|
+
return result.data;
|
|
147
|
+
}
|
|
148
|
+
// ──────────────────────────────────────────────────────────
|
|
149
|
+
// Post Groups
|
|
150
|
+
// ──────────────────────────────────────────────────────────
|
|
151
|
+
/**
|
|
152
|
+
* GET /api/companies/{id}/postGroups?sort=-id&...
|
|
153
|
+
* `sort=-id` is REQUIRED in practice. Without it Followr returns the 30 oldest, which
|
|
154
|
+
* is useless for almost any caller. Default kept here defensively.
|
|
155
|
+
*/
|
|
156
|
+
async listCompanyPostGroups(companyId, options) {
|
|
157
|
+
const query = {
|
|
158
|
+
sort: options?.sort ?? "-id",
|
|
159
|
+
"page[size]": options?.pageSize ?? 30,
|
|
160
|
+
"page[number]": options?.pageNumber ?? 1
|
|
161
|
+
};
|
|
162
|
+
if (options?.include)
|
|
163
|
+
query["include"] = options.include;
|
|
164
|
+
if (options?.draft !== void 0)
|
|
165
|
+
query["filter[draft]"] = options.draft ? 1 : 0;
|
|
166
|
+
if (options?.publishAtAfter)
|
|
167
|
+
query["filter[publish_at_after]"] = options.publishAtAfter;
|
|
168
|
+
if (options?.publishAtBefore)
|
|
169
|
+
query["filter[publish_at_before]"] = options.publishAtBefore;
|
|
170
|
+
if (options?.publishAtNull)
|
|
171
|
+
query["filter[publish_at_null]"] = 1;
|
|
172
|
+
if (options?.socialNetworkTypes?.length) {
|
|
173
|
+
query["filter[posts.social_network_type]"] = options.socialNetworkTypes;
|
|
174
|
+
}
|
|
175
|
+
if (options?.ignoreTags !== void 0)
|
|
176
|
+
query["filter[ignoreTags]"] = options.ignoreTags;
|
|
177
|
+
if (options?.hasRelation !== void 0)
|
|
178
|
+
query["filter[has_relation]"] = options.hasRelation ? 1 : 0;
|
|
179
|
+
const result = await this.request("GET", `/api/companies/${companyId}/postGroups`, { query });
|
|
180
|
+
return result.data;
|
|
181
|
+
}
|
|
182
|
+
/** GET /api/postGroups/{id} with full include chain to hydrate assets. */
|
|
183
|
+
async getPostGroup(postGroupId, options) {
|
|
184
|
+
const include = options?.include ?? "posts,posts.assets,posts.assets.image,posts.assets.image.thumbnail,posts.assets.video,posts.assets.video.thumbnail,tags,user,ruleGroup";
|
|
185
|
+
const result = await this.request("GET", `/api/postGroups/${postGroupId}`, {
|
|
186
|
+
query: { include }
|
|
187
|
+
});
|
|
188
|
+
return result.data;
|
|
189
|
+
}
|
|
190
|
+
/** POST /api/companies/{id}/postGroups */
|
|
191
|
+
async createPostGroup(companyId, body) {
|
|
192
|
+
const result = await this.request("POST", `/api/companies/${companyId}/postGroups`, { body });
|
|
193
|
+
return result.data;
|
|
194
|
+
}
|
|
195
|
+
/** PUT /api/postGroups/{id}. Caller must merge tags_ids (it's REPLACE, not append). */
|
|
196
|
+
async updatePostGroup(postGroupId, patch) {
|
|
197
|
+
const result = await this.request("PUT", `/api/postGroups/${postGroupId}`, {
|
|
198
|
+
body: patch
|
|
199
|
+
});
|
|
200
|
+
return result.data;
|
|
201
|
+
}
|
|
202
|
+
/** DELETE /api/postGroups/{id} */
|
|
203
|
+
async deletePostGroup(postGroupId) {
|
|
204
|
+
await this.request("DELETE", `/api/postGroups/${postGroupId}`);
|
|
205
|
+
}
|
|
206
|
+
/** POST /api/postGroups/{id}/publish. Force-publish to a specific network now. */
|
|
207
|
+
async publishPostGroup(postGroupId, socialNetworkType) {
|
|
208
|
+
return this.request("POST", `/api/postGroups/${postGroupId}/publish`, {
|
|
209
|
+
body: { social_network_type: socialNetworkType }
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// ──────────────────────────────────────────────────────────
|
|
213
|
+
// Posts (per-network within a PostGroup)
|
|
214
|
+
// ──────────────────────────────────────────────────────────
|
|
215
|
+
/** POST /api/postGroups/{id}/posts. Creates one post per social network. */
|
|
216
|
+
async createPost(postGroupId, body) {
|
|
217
|
+
return this.request("POST", `/api/postGroups/${postGroupId}/posts`, { body });
|
|
218
|
+
}
|
|
219
|
+
// ──────────────────────────────────────────────────────────
|
|
220
|
+
// Tags (CRUD complete, sessions 5 + 6 verified empirically)
|
|
221
|
+
// ──────────────────────────────────────────────────────────
|
|
222
|
+
async listTags(companyId, options) {
|
|
223
|
+
const result = await this.request("GET", "/api/tags", {
|
|
224
|
+
query: { "filter[company_id]": companyId, "page[size]": options?.pageSize ?? 100 }
|
|
225
|
+
});
|
|
226
|
+
return result.data;
|
|
227
|
+
}
|
|
228
|
+
async createTag(body) {
|
|
229
|
+
const result = await this.request("POST", "/api/tags", { body });
|
|
230
|
+
return result.data;
|
|
231
|
+
}
|
|
232
|
+
async updateTag(tagId, patch) {
|
|
233
|
+
const result = await this.request("PUT", `/api/tags/${tagId}`, { body: patch });
|
|
234
|
+
return result.data;
|
|
235
|
+
}
|
|
236
|
+
async deleteTag(tagId) {
|
|
237
|
+
await this.request("DELETE", `/api/tags/${tagId}`);
|
|
238
|
+
}
|
|
239
|
+
// ──────────────────────────────────────────────────────────
|
|
240
|
+
// Folders (CREATE nested, GET/PUT/DELETE flat. Verified.)
|
|
241
|
+
// ──────────────────────────────────────────────────────────
|
|
242
|
+
async listFolders(companyId, options) {
|
|
243
|
+
const query = { "page[size]": options?.pageSize ?? 30 };
|
|
244
|
+
if (options?.parentId !== void 0)
|
|
245
|
+
query["filter[parent_id]"] = options.parentId === null ? "null" : options.parentId;
|
|
246
|
+
const result = await this.request("GET", `/api/companies/${companyId}/folders`, { query });
|
|
247
|
+
return result.data;
|
|
248
|
+
}
|
|
249
|
+
async getFolder(folderId) {
|
|
250
|
+
const result = await this.request("GET", `/api/folders/${folderId}`);
|
|
251
|
+
return result.data;
|
|
252
|
+
}
|
|
253
|
+
async createFolder(companyId, body) {
|
|
254
|
+
const result = await this.request("POST", `/api/companies/${companyId}/folders`, { body });
|
|
255
|
+
return result.data;
|
|
256
|
+
}
|
|
257
|
+
async updateFolder(folderId, patch) {
|
|
258
|
+
const result = await this.request("PUT", `/api/folders/${folderId}`, { body: patch });
|
|
259
|
+
return result.data;
|
|
260
|
+
}
|
|
261
|
+
async deleteFolder(folderId) {
|
|
262
|
+
await this.request("DELETE", `/api/folders/${folderId}`);
|
|
263
|
+
}
|
|
264
|
+
// ──────────────────────────────────────────────────────────
|
|
265
|
+
// RuleGroups (Autopilot)
|
|
266
|
+
// ──────────────────────────────────────────────────────────
|
|
267
|
+
async listRuleGroups(companyId, options) {
|
|
268
|
+
const result = await this.request("GET", "/api/ruleGroups", {
|
|
269
|
+
query: { "filter[company_id]": companyId, ...options?.include ? { include: options.include } : {} }
|
|
270
|
+
});
|
|
271
|
+
return result.data;
|
|
272
|
+
}
|
|
273
|
+
async getRuleGroup(ruleGroupId) {
|
|
274
|
+
const result = await this.request("GET", `/api/ruleGroups/${ruleGroupId}`);
|
|
275
|
+
return result.data;
|
|
276
|
+
}
|
|
277
|
+
async createRuleGroup(body) {
|
|
278
|
+
const result = await this.request("POST", "/api/ruleGroups", { body });
|
|
279
|
+
return result.data;
|
|
280
|
+
}
|
|
281
|
+
async updateRuleGroup(ruleGroupId, patch) {
|
|
282
|
+
const result = await this.request("PUT", `/api/ruleGroups/${ruleGroupId}`, {
|
|
283
|
+
body: patch
|
|
284
|
+
});
|
|
285
|
+
return result.data;
|
|
286
|
+
}
|
|
287
|
+
async deleteRuleGroup(ruleGroupId) {
|
|
288
|
+
await this.request("DELETE", `/api/ruleGroups/${ruleGroupId}`);
|
|
289
|
+
}
|
|
290
|
+
// ──────────────────────────────────────────────────────────
|
|
291
|
+
// Voices
|
|
292
|
+
// ──────────────────────────────────────────────────────────
|
|
293
|
+
async listVoices(companyId, options) {
|
|
294
|
+
const result = await this.request("GET", `/api/companies/${companyId}/voices`, { query: { "page[size]": options?.pageSize ?? 30 } });
|
|
295
|
+
return result.data;
|
|
296
|
+
}
|
|
297
|
+
async getVoice(voiceId) {
|
|
298
|
+
const result = await this.request("GET", `/api/voices/${voiceId}`);
|
|
299
|
+
return result.data;
|
|
300
|
+
}
|
|
301
|
+
async listElevenlabsVoices(options) {
|
|
302
|
+
const result = await this.request("GET", "/api/voices/elevenlabs", {
|
|
303
|
+
query: { page: options?.page ?? 1 }
|
|
304
|
+
});
|
|
305
|
+
return result.data;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* POST /api/companies/{companyId}/voices. Creates a voice profile linked to a
|
|
309
|
+
* TTS provider (e.g. ElevenLabs).
|
|
310
|
+
*
|
|
311
|
+
* Note: the path is NESTED under the company. The internal doc previously
|
|
312
|
+
* listed this as `POST /api/voices` (flat) but that route returns 404. The
|
|
313
|
+
* nested path was verified empirically 2026-05-14.
|
|
314
|
+
*/
|
|
315
|
+
async createVoice(companyId, body) {
|
|
316
|
+
const result = await this.request("POST", `/api/companies/${companyId}/voices`, { body });
|
|
317
|
+
return result.data;
|
|
318
|
+
}
|
|
319
|
+
// ──────────────────────────────────────────────────────────
|
|
320
|
+
// Avatars
|
|
321
|
+
// ──────────────────────────────────────────────────────────
|
|
322
|
+
async listAvatars(companyId, options) {
|
|
323
|
+
const result = await this.request("GET", `/api/companies/${companyId}/avatars`, {
|
|
324
|
+
query: {
|
|
325
|
+
include: options?.include ?? "image.thumbnail,scenes,scenes.thumbnail,voice,voice.audio",
|
|
326
|
+
"page[size]": options?.pageSize ?? 30
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
return result.data;
|
|
330
|
+
}
|
|
331
|
+
async getAvatar(avatarId, options) {
|
|
332
|
+
const result = await this.request("GET", `/api/avatars/${avatarId}`, {
|
|
333
|
+
query: options?.include ? { include: options.include } : void 0
|
|
334
|
+
});
|
|
335
|
+
return result.data;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* POST /api/companies/{companyId}/avatars. Creates a custom avatar resource
|
|
339
|
+
* (without image; image attached in next step). Path is NESTED under the
|
|
340
|
+
* company, same as the other CREATE endpoints (voices, folders, postGroups).
|
|
341
|
+
* The internal doc previously listed this as POST /api/avatars (flat) but
|
|
342
|
+
* that route returns 404. Verified empirically 2026-05-14.
|
|
343
|
+
*/
|
|
344
|
+
async createAvatar(companyId, body) {
|
|
345
|
+
const result = await this.request("POST", `/api/companies/${companyId}/avatars`, { body });
|
|
346
|
+
return result.data;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* PUT /api/avatars/{id}.
|
|
350
|
+
* Endpoint verified empirically 2026-05-13 (PUT with non-existent id returns 404 ModelNotFoundException,
|
|
351
|
+
* confirming the route exists and accepts PUT, same Laravel pattern as tags/folders/ruleGroups).
|
|
352
|
+
*/
|
|
353
|
+
async updateAvatar(avatarId, patch) {
|
|
354
|
+
const result = await this.request("PUT", `/api/avatars/${avatarId}`, { body: patch });
|
|
355
|
+
return result.data;
|
|
356
|
+
}
|
|
357
|
+
/** DELETE /api/avatars/{id}. Verified empirically 2026-05-13 (same ModelNotFoundException probe pattern). */
|
|
358
|
+
async deleteAvatar(avatarId) {
|
|
359
|
+
await this.request("DELETE", `/api/avatars/${avatarId}`);
|
|
360
|
+
}
|
|
361
|
+
/** POST /api/avatars/{id}/image. Step 1 of 3-step image upload for avatar. Returns presigned URL. */
|
|
362
|
+
async requestAvatarImageUpload(avatarId, body) {
|
|
363
|
+
const result = await this.request("POST", `/api/avatars/${avatarId}/image`, { body });
|
|
364
|
+
return result.data;
|
|
365
|
+
}
|
|
366
|
+
// ──────────────────────────────────────────────────────────
|
|
367
|
+
// Social Hub
|
|
368
|
+
// ──────────────────────────────────────────────────────────
|
|
369
|
+
async listConversations(companyId, options) {
|
|
370
|
+
const query = {
|
|
371
|
+
"filter[externalUser.company_id]": companyId,
|
|
372
|
+
"page[size]": options?.pageSize ?? 30,
|
|
373
|
+
include: "lastMessage,unreadMessagesCount,externalUser.image"
|
|
374
|
+
};
|
|
375
|
+
if (options?.socialNetworkId !== void 0)
|
|
376
|
+
query["filter[social_network_id]"] = options.socialNetworkId;
|
|
377
|
+
if (options?.hasUnreadMessages)
|
|
378
|
+
query["filter[has_unreadMessages]"] = 1;
|
|
379
|
+
const result = await this.request("GET", "/api/conversations", { query });
|
|
380
|
+
return result.data;
|
|
381
|
+
}
|
|
382
|
+
async listMessages(conversationId, options) {
|
|
383
|
+
const result = await this.request("GET", "/api/messages", {
|
|
384
|
+
query: { "filter[conversation_id]": conversationId, "page[size]": options?.pageSize ?? 30 }
|
|
385
|
+
});
|
|
386
|
+
return result.data;
|
|
387
|
+
}
|
|
388
|
+
/** Platform-specific message reading (Facebook + Instagram only). */
|
|
389
|
+
async listPlatformMessages(platform, conversationId) {
|
|
390
|
+
const result = await this.request("GET", `/api/${platform}/conversations/${conversationId}/messages`);
|
|
391
|
+
return result.data;
|
|
392
|
+
}
|
|
393
|
+
async markConversationRead(conversationId) {
|
|
394
|
+
const result = await this.request("PUT", `/api/conversations/${conversationId}`, { body: { is_read: true } });
|
|
395
|
+
return result.data;
|
|
396
|
+
}
|
|
397
|
+
async listExternalUsers(companyId, options) {
|
|
398
|
+
const query = { "filter[company_id]": companyId, "page[size]": options?.pageSize ?? 30 };
|
|
399
|
+
if (options?.type)
|
|
400
|
+
query["filter[type]"] = options.type;
|
|
401
|
+
const result = await this.request("GET", "/api/externalUsers", { query });
|
|
402
|
+
return result.data;
|
|
403
|
+
}
|
|
404
|
+
// ──────────────────────────────────────────────────────────
|
|
405
|
+
// Prompts (brand-voice prompts, AKA "social_network_prompts")
|
|
406
|
+
// ──────────────────────────────────────────────────────────
|
|
407
|
+
//
|
|
408
|
+
// The /api/prompts resource is the source of truth for the brand-voice
|
|
409
|
+
// feature exposed in the Followr UI under Company Settings → Prompts.
|
|
410
|
+
// Each prompt belongs to a (company_id, social_network_type) tuple, has a
|
|
411
|
+
// `default` flag, and can be selected at generate time. Multiple prompts
|
|
412
|
+
// with default=true per network are allowed; Followr picks one.
|
|
413
|
+
//
|
|
414
|
+
// The legacy field `Company.social_network_prompts` is a denormalized
|
|
415
|
+
// mirror of this resource and is read-only via PUT /api/companies/{id}.
|
|
416
|
+
// Discovered empirically 2026-05-14.
|
|
417
|
+
async listPrompts(options) {
|
|
418
|
+
const query = {
|
|
419
|
+
// company_id=null surfaces the global Followr defaults; numeric scopes
|
|
420
|
+
// to the workspace.
|
|
421
|
+
"filter[company_id]": options.companyId === null ? "null" : options.companyId,
|
|
422
|
+
"page[size]": options.pageSize ?? 30,
|
|
423
|
+
sort: options.sort ?? "-created_at"
|
|
424
|
+
};
|
|
425
|
+
if (options.socialNetworkType)
|
|
426
|
+
query["filter[social_network_type]"] = options.socialNetworkType;
|
|
427
|
+
if (options.onlyDefault)
|
|
428
|
+
query["filter[default]"] = 1;
|
|
429
|
+
if (options.include)
|
|
430
|
+
query["include"] = options.include;
|
|
431
|
+
const result = await this.request("GET", "/api/prompts", { query });
|
|
432
|
+
return result.data;
|
|
433
|
+
}
|
|
434
|
+
async getPrompt(promptId) {
|
|
435
|
+
const result = await this.request("GET", `/api/prompts/${promptId}`);
|
|
436
|
+
return result.data;
|
|
437
|
+
}
|
|
438
|
+
async createPrompt(body) {
|
|
439
|
+
const result = await this.request("POST", "/api/prompts", {
|
|
440
|
+
body: { type: "text", default: false, ...body }
|
|
441
|
+
});
|
|
442
|
+
return result.data;
|
|
443
|
+
}
|
|
444
|
+
async updatePrompt(promptId, patch) {
|
|
445
|
+
const result = await this.request("PUT", `/api/prompts/${promptId}`, { body: patch });
|
|
446
|
+
return result.data;
|
|
447
|
+
}
|
|
448
|
+
async deletePrompt(promptId) {
|
|
449
|
+
await this.request("DELETE", `/api/prompts/${promptId}`);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* GET /api/comments. Filter is `externalUser.company_id` (deep relation, not `company_id` directly).
|
|
453
|
+
* Verified empirically in session 5 (2026-05-13).
|
|
454
|
+
*/
|
|
455
|
+
async listComments(companyId, options) {
|
|
456
|
+
const query = {
|
|
457
|
+
"filter[externalUser.company_id]": companyId,
|
|
458
|
+
"page[size]": options?.pageSize ?? 30
|
|
459
|
+
};
|
|
460
|
+
if (options?.postId !== void 0)
|
|
461
|
+
query["filter[post_id]"] = options.postId;
|
|
462
|
+
if (options?.include)
|
|
463
|
+
query["include"] = options.include;
|
|
464
|
+
const result = await this.request("GET", "/api/comments", { query });
|
|
465
|
+
return result.data;
|
|
466
|
+
}
|
|
467
|
+
// ──────────────────────────────────────────────────────────
|
|
468
|
+
// Analytics
|
|
469
|
+
// ──────────────────────────────────────────────────────────
|
|
470
|
+
async listSocialNetworkPostMetrics(socialNetworkId, options) {
|
|
471
|
+
const query = { since: options.since, until: options.until };
|
|
472
|
+
if (options.fields)
|
|
473
|
+
query["fields"] = options.fields;
|
|
474
|
+
if (options.limit)
|
|
475
|
+
query["limit"] = options.limit;
|
|
476
|
+
const result = await this.request("GET", `/api/socialNetworks/${socialNetworkId}/posts`, { query });
|
|
477
|
+
return result.data;
|
|
478
|
+
}
|
|
479
|
+
// ──────────────────────────────────────────────────────────
|
|
480
|
+
// Social Networks (connected accounts)
|
|
481
|
+
// ──────────────────────────────────────────────────────────
|
|
482
|
+
async listSocialNetworks(companyId) {
|
|
483
|
+
const result = await this.request("GET", `/api/companies/${companyId}/socialNetworks`);
|
|
484
|
+
return result.data;
|
|
485
|
+
}
|
|
486
|
+
// ──────────────────────────────────────────────────────────
|
|
487
|
+
// Subscription / credits
|
|
488
|
+
// ──────────────────────────────────────────────────────────
|
|
489
|
+
async getSubscriptionBalance() {
|
|
490
|
+
const result = await this.request("GET", "/api/subscriptions/balance");
|
|
491
|
+
return result.data;
|
|
492
|
+
}
|
|
493
|
+
// ──────────────────────────────────────────────────────────
|
|
494
|
+
// AI Results (master endpoint for all AI generation)
|
|
495
|
+
// ──────────────────────────────────────────────────────────
|
|
496
|
+
/** POST /api/aiResults/chat. Text generation. */
|
|
497
|
+
async generateChat(body) {
|
|
498
|
+
const result = await this.request("POST", "/api/aiResults/chat", { body });
|
|
499
|
+
return result.data;
|
|
500
|
+
}
|
|
501
|
+
/** POST /api/aiResults/image. */
|
|
502
|
+
async generateImage(body) {
|
|
503
|
+
const result = await this.request("POST", "/api/aiResults/image", { body });
|
|
504
|
+
return result.data;
|
|
505
|
+
}
|
|
506
|
+
/** POST /api/aiResults/audio. TTS. */
|
|
507
|
+
async generateAudio(body) {
|
|
508
|
+
const result = await this.request("POST", "/api/aiResults/audio", { body });
|
|
509
|
+
return result.data;
|
|
510
|
+
}
|
|
511
|
+
/** POST /api/aiResults/video. Lipsync video generation. */
|
|
512
|
+
async generateVideo(body) {
|
|
513
|
+
const result = await this.request("POST", "/api/aiResults/video", { body });
|
|
514
|
+
return result.data;
|
|
515
|
+
}
|
|
516
|
+
/** GET /api/aiResults/{id}. Polling endpoint. */
|
|
517
|
+
async getAiResult(aiResultId) {
|
|
518
|
+
const result = await this.request("GET", `/api/aiResults/${aiResultId}`);
|
|
519
|
+
return result.data;
|
|
520
|
+
}
|
|
521
|
+
/** Convenience: poll until completed or failed. */
|
|
522
|
+
async waitForAiResult(aiResultId, options) {
|
|
523
|
+
const intervalMs = options?.intervalMs ?? 2500;
|
|
524
|
+
const timeoutMs = options?.timeoutMs ?? 5 * 6e4;
|
|
525
|
+
const start = Date.now();
|
|
526
|
+
while (Date.now() - start < timeoutMs) {
|
|
527
|
+
const result = await this.getAiResult(aiResultId);
|
|
528
|
+
if (result.status === "completed" || result.status === "failed")
|
|
529
|
+
return result;
|
|
530
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
531
|
+
}
|
|
532
|
+
throw new FollowrApiError(`Timeout waiting for aiResult ${aiResultId} (>${timeoutMs}ms)`, 408, `/api/aiResults/${aiResultId}`);
|
|
533
|
+
}
|
|
534
|
+
async listAiResults(options) {
|
|
535
|
+
const query = {
|
|
536
|
+
"filter[company_id]": options.companyId,
|
|
537
|
+
"page[size]": options.pageSize ?? 30,
|
|
538
|
+
sort: options.sort ?? "-created_at"
|
|
539
|
+
};
|
|
540
|
+
if (options.type)
|
|
541
|
+
query["filter[type]"] = options.type;
|
|
542
|
+
if (options.model)
|
|
543
|
+
query["filter[model]"] = options.model;
|
|
544
|
+
if (options.include)
|
|
545
|
+
query["include"] = options.include;
|
|
546
|
+
const result = await this.request("GET", "/api/aiResults", { query });
|
|
547
|
+
return result.data;
|
|
548
|
+
}
|
|
549
|
+
// ──────────────────────────────────────────────────────────
|
|
550
|
+
// Canva integration
|
|
551
|
+
// ──────────────────────────────────────────────────────────
|
|
552
|
+
async listCanvaDesigns(companyId, options) {
|
|
553
|
+
const query = {};
|
|
554
|
+
if (options?.search)
|
|
555
|
+
query["search"] = options.search;
|
|
556
|
+
if (options?.limit)
|
|
557
|
+
query["limit"] = options.limit;
|
|
558
|
+
if (options?.continuationToken)
|
|
559
|
+
query["continuation_token"] = options.continuationToken;
|
|
560
|
+
const result = await this.request("GET", `/api/companies/${companyId}/canva/designs`, { query });
|
|
561
|
+
return result.data;
|
|
562
|
+
}
|
|
563
|
+
async startCanvaDesignExport(companyId, body) {
|
|
564
|
+
const result = await this.request("POST", `/api/companies/${companyId}/canva/designExportJob`, { body });
|
|
565
|
+
return result.data;
|
|
566
|
+
}
|
|
567
|
+
async getCanvaDesignExportJob(companyId, jobId) {
|
|
568
|
+
const result = await this.request("GET", `/api/companies/${companyId}/canva/designExportJob/${jobId}`);
|
|
569
|
+
return result.data;
|
|
570
|
+
}
|
|
571
|
+
// ──────────────────────────────────────────────────────────
|
|
572
|
+
// Asset upload (3-step pattern)
|
|
573
|
+
// ──────────────────────────────────────────────────────────
|
|
574
|
+
/** Step 1: create asset placeholder under company. */
|
|
575
|
+
async createAsset(companyId, body) {
|
|
576
|
+
const result = await this.request("POST", `/api/companies/${companyId}/assets`, { body });
|
|
577
|
+
return result.data;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* GET /api/companies/{id}/assets. List assets in a workspace, optionally filtered by type and folder.
|
|
581
|
+
* Verified empirically (used by bulkuploader).
|
|
582
|
+
*/
|
|
583
|
+
async listAssets(companyId, options) {
|
|
584
|
+
const query = { "page[size]": options?.pageSize ?? 30 };
|
|
585
|
+
if (options?.type)
|
|
586
|
+
query["filter[type]"] = options.type;
|
|
587
|
+
if (options?.folderId !== void 0) {
|
|
588
|
+
query["filter[folder_id]"] = options.folderId === null ? "null" : options.folderId;
|
|
589
|
+
}
|
|
590
|
+
if (options?.include)
|
|
591
|
+
query["include"] = options.include;
|
|
592
|
+
const result = await this.request("GET", `/api/companies/${companyId}/assets`, { query });
|
|
593
|
+
return result.data;
|
|
594
|
+
}
|
|
595
|
+
/** Step 2: request presigned upload URL for the asset. */
|
|
596
|
+
async requestAssetUpload(assetId, kind, body) {
|
|
597
|
+
const result = await this.request("POST", `/api/assets/${assetId}/${kind}`, { body });
|
|
598
|
+
return result.data;
|
|
599
|
+
}
|
|
600
|
+
/** Step 3: PUT binary to the Azure presigned URL. Caller responsibility, but helper provided. */
|
|
601
|
+
async uploadToBlob(presignedUrl, binary, contentType) {
|
|
602
|
+
const response = await this.fetchImpl(presignedUrl, {
|
|
603
|
+
method: "PUT",
|
|
604
|
+
headers: {
|
|
605
|
+
"x-ms-blob-type": "BlockBlob",
|
|
606
|
+
"Content-Type": contentType
|
|
607
|
+
},
|
|
608
|
+
// ts-prune-ignore-next
|
|
609
|
+
body: binary
|
|
610
|
+
});
|
|
611
|
+
if (!response.ok) {
|
|
612
|
+
throw new FollowrApiError(`Azure blob upload failed: ${response.status}`, response.status, presignedUrl);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// ../mcp-core/dist/tools/ai-results.js
|
|
618
|
+
import { z } from "zod";
|
|
619
|
+
function sanitizeAiResult(result) {
|
|
620
|
+
const { use_own_key: _omit, ...safe } = result;
|
|
621
|
+
return safe;
|
|
622
|
+
}
|
|
623
|
+
function registerAiResultsTools(server2, client2, _options) {
|
|
624
|
+
server2.registerTool("generate_text", {
|
|
625
|
+
title: "Generate text with Followr AI (chat completion)",
|
|
626
|
+
description: "Generate text using Followr's AI text endpoint. Use this for any prompt-based text task: brainstorm ideas, draft copy, rewrite in a different tone, translate, suggest hashtags, summarize an article. The caller passes an arbitrary prompt. Default driver is openai with gpt-4.1-mini; override via the model and driver params if a different LLM is preferred. By default this tool blocks until the result is completed (set wait=false to return immediately with a pending id).",
|
|
627
|
+
inputSchema: {
|
|
628
|
+
company_id: z.number().int().positive().describe("The Followr company id (workspace)."),
|
|
629
|
+
prompt: z.string().min(1).describe("The full prompt to send to the model."),
|
|
630
|
+
driver: z.string().optional().describe("Optional provider override. Visto: openai, anthropic, deepseek. Default uses the workspace's text_driver."),
|
|
631
|
+
model: z.string().optional().describe("Optional model override. e.g. gpt-4.1-mini, claude-sonnet-4-5, deepseek-chat. Default uses the workspace's text_model."),
|
|
632
|
+
queue: z.boolean().optional().describe("If true, run async via queue. Default true (matches SPA behavior)."),
|
|
633
|
+
wait: z.boolean().optional().default(true).describe("If true (default), poll until the result is completed or failed and return the final result. If false, return the initial pending result."),
|
|
634
|
+
timeout_seconds: z.number().int().positive().max(600).optional().describe("Max seconds to wait for completion when wait=true. Default 300.")
|
|
635
|
+
}
|
|
636
|
+
}, async ({ company_id, prompt, driver, model, queue, wait, timeout_seconds }) => {
|
|
637
|
+
const initial = await client2.generateChat({
|
|
638
|
+
q: prompt,
|
|
639
|
+
company_id,
|
|
640
|
+
...driver ? { driver } : {},
|
|
641
|
+
...model ? { model } : {},
|
|
642
|
+
...queue !== void 0 ? { queue } : {}
|
|
643
|
+
});
|
|
644
|
+
if (!wait || initial.status === "completed" || initial.status === "failed") {
|
|
645
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(initial), null, 2) }] };
|
|
646
|
+
}
|
|
647
|
+
const final = await client2.waitForAiResult(initial.id, {
|
|
648
|
+
timeoutMs: (timeout_seconds ?? 300) * 1e3
|
|
649
|
+
});
|
|
650
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(final), null, 2) }] };
|
|
651
|
+
});
|
|
652
|
+
server2.registerTool("generate_image", {
|
|
653
|
+
title: "Generate an image with Followr AI",
|
|
654
|
+
description: "Generate an image using Followr's AI image endpoint. Supports image-to-image consistency via image_url (use this to keep a subject consistent across multiple generations, e.g. avatar scenes). Default driver fal with model nano_banana_2; override for Recraft, OpenAI DALL-E, etc. Costs around 25 credits per image. Returns the completed result including image_url when wait=true (default).",
|
|
655
|
+
inputSchema: {
|
|
656
|
+
company_id: z.number().int().positive(),
|
|
657
|
+
prompt: z.string().min(1).describe("Visual prompt describing the desired image."),
|
|
658
|
+
aspect_ratio: z.enum(["1:1", "9:16", "16:9", "4:5"]).optional().describe("Output aspect ratio. 1:1 square (default), 9:16 vertical/story, 16:9 horizontal, 4:5 portrait."),
|
|
659
|
+
image_url: z.string().url().optional().describe("Reference image URL for image-to-image. Useful for keeping a subject consistent."),
|
|
660
|
+
image_urls: z.array(z.string().url()).optional().describe("Multiple reference images (when applicable)."),
|
|
661
|
+
n: z.number().int().positive().max(4).optional().describe("Number of images to generate. Default 1."),
|
|
662
|
+
driver: z.string().optional().describe("Optional provider override. Visto: fal, openai, recraft."),
|
|
663
|
+
model: z.string().optional().describe("Optional model override. Visto: nano_banana_2."),
|
|
664
|
+
queue: z.boolean().optional(),
|
|
665
|
+
wait: z.boolean().optional().default(true),
|
|
666
|
+
timeout_seconds: z.number().int().positive().max(600).optional()
|
|
667
|
+
}
|
|
668
|
+
}, async ({ company_id, prompt, aspect_ratio, image_url, image_urls, n, driver, model, queue, wait, timeout_seconds }) => {
|
|
669
|
+
const initial = await client2.generateImage({
|
|
670
|
+
q: prompt,
|
|
671
|
+
company_id,
|
|
672
|
+
...aspect_ratio ? { aspect_ratio } : {},
|
|
673
|
+
...image_url ? { image_url } : {},
|
|
674
|
+
...image_urls?.length ? { image_urls } : {},
|
|
675
|
+
...n ? { n } : {},
|
|
676
|
+
// Defaults verified empirically: fal + nano_banana_2. The workspace's
|
|
677
|
+
// ai_preferences.image_model is not automatically applied by the
|
|
678
|
+
// backend when omitted, so we set explicit defaults here.
|
|
679
|
+
driver: driver ?? "fal",
|
|
680
|
+
model: model ?? "nano_banana_2",
|
|
681
|
+
...queue !== void 0 ? { queue } : {}
|
|
682
|
+
});
|
|
683
|
+
if (!wait || initial.status === "completed" || initial.status === "failed") {
|
|
684
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(initial), null, 2) }] };
|
|
685
|
+
}
|
|
686
|
+
const final = await client2.waitForAiResult(initial.id, {
|
|
687
|
+
timeoutMs: (timeout_seconds ?? 300) * 1e3
|
|
688
|
+
});
|
|
689
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(final), null, 2) }] };
|
|
690
|
+
});
|
|
691
|
+
server2.registerTool("generate_audio", {
|
|
692
|
+
title: "Generate TTS audio with Followr AI",
|
|
693
|
+
description: "Generate text-to-speech audio. Requires a voice identifier (use list_voices to find the platform_external_id of a workspace voice, or list_elevenlabs_voices for ElevenLabs voice_ids). Use this to narrate scripts, create podcast snippets, or pre-generate audio for avatar videos. Returns the completed result including audio_url when wait=true.",
|
|
694
|
+
inputSchema: {
|
|
695
|
+
company_id: z.number().int().positive(),
|
|
696
|
+
text: z.string().min(1).describe("The text to speak."),
|
|
697
|
+
voice: z.string().min(1).describe("Voice identifier. Either a Voice.platform_external_id from list_voices or an ElevenLabs voice_id from list_elevenlabs_voices."),
|
|
698
|
+
speed: z.number().min(0.5).max(2).optional().describe("Speech speed multiplier. Default 1.0."),
|
|
699
|
+
driver: z.string().optional().describe("TTS provider. Default fal. ElevenLabs also supported."),
|
|
700
|
+
model: z.string().optional().describe("TTS model id (provider-specific)."),
|
|
701
|
+
queue: z.boolean().optional(),
|
|
702
|
+
wait: z.boolean().optional().default(true),
|
|
703
|
+
timeout_seconds: z.number().int().positive().max(600).optional()
|
|
704
|
+
}
|
|
705
|
+
}, async ({ company_id, text, voice, speed, driver, model, queue, wait, timeout_seconds }) => {
|
|
706
|
+
const initial = await client2.generateAudio({
|
|
707
|
+
q: text,
|
|
708
|
+
company_id,
|
|
709
|
+
type: "audio",
|
|
710
|
+
voice,
|
|
711
|
+
...speed !== void 0 ? { speed } : {},
|
|
712
|
+
// Defaults verified empirically: fal + elevenlabs_tts_3.
|
|
713
|
+
driver: driver ?? "fal",
|
|
714
|
+
model: model ?? "elevenlabs_tts_3",
|
|
715
|
+
...queue !== void 0 ? { queue } : {}
|
|
716
|
+
});
|
|
717
|
+
if (!wait || initial.status === "completed" || initial.status === "failed") {
|
|
718
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(initial), null, 2) }] };
|
|
719
|
+
}
|
|
720
|
+
const final = await client2.waitForAiResult(initial.id, {
|
|
721
|
+
timeoutMs: (timeout_seconds ?? 300) * 1e3
|
|
722
|
+
});
|
|
723
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(final), null, 2) }] };
|
|
724
|
+
});
|
|
725
|
+
server2.registerTool("generate_avatar_video", {
|
|
726
|
+
title: "Generate a lipsync video using an existing avatar",
|
|
727
|
+
description: "Workflow tool that produces a single avatar lipsync video clip. Steps internally: 1) fetch the avatar's voice and image, 2) generate TTS audio for the script using the avatar's voice, 3) wait for audio completion, 4) generate the lipsync video. Heavy operation: 775 credits Regular, 930 Fast. Pre-existing avatar required (use list_avatars or create_avatar_full_flow first). For multi-scene videos, call this once per scene.",
|
|
728
|
+
inputSchema: {
|
|
729
|
+
company_id: z.number().int().positive(),
|
|
730
|
+
avatar_id: z.number().int().positive().describe("The avatar to use (must have a voice and image already set)."),
|
|
731
|
+
script: z.string().min(1).describe("Text the avatar will say in this scene. Typical 100-150 chars."),
|
|
732
|
+
aspect_ratio: z.enum(["9:16", "16:9", "1:1"]).optional().describe("Default 9:16 (viral short)."),
|
|
733
|
+
driver: z.string().optional().describe("Default fal."),
|
|
734
|
+
model: z.string().optional().describe("Lipsync model id (provider-specific)."),
|
|
735
|
+
audio_speed: z.number().min(0.5).max(2).optional().describe("TTS speed. Default 1.0."),
|
|
736
|
+
timeout_seconds: z.number().int().positive().max(900).optional().describe("Max seconds for video to complete. Default 600.")
|
|
737
|
+
}
|
|
738
|
+
}, async ({ company_id, avatar_id, script, aspect_ratio, driver, model, audio_speed, timeout_seconds }) => {
|
|
739
|
+
const avatar = await client2.getAvatar(avatar_id, {
|
|
740
|
+
include: "image,voice,voice.audio"
|
|
741
|
+
});
|
|
742
|
+
const voicePlatformId = avatar.voice?.platform_external_id;
|
|
743
|
+
const imageUrl = avatar.image?.url;
|
|
744
|
+
if (!voicePlatformId) {
|
|
745
|
+
throw new Error(`Avatar ${avatar_id} has no voice.platform_external_id. Assign a voice before generating videos.`);
|
|
746
|
+
}
|
|
747
|
+
if (!imageUrl) {
|
|
748
|
+
throw new Error(`Avatar ${avatar_id} has no image.url. Attach an image (via create_avatar_full_flow) first.`);
|
|
749
|
+
}
|
|
750
|
+
const audioInitial = await client2.generateAudio({
|
|
751
|
+
q: script,
|
|
752
|
+
company_id,
|
|
753
|
+
type: "audio",
|
|
754
|
+
voice: voicePlatformId,
|
|
755
|
+
...audio_speed !== void 0 ? { speed: audio_speed } : {},
|
|
756
|
+
driver: "fal",
|
|
757
|
+
model: "elevenlabs_tts_3"
|
|
758
|
+
});
|
|
759
|
+
const audioFinal = await client2.waitForAiResult(audioInitial.id, {
|
|
760
|
+
timeoutMs: (timeout_seconds ?? 600) * 1e3
|
|
761
|
+
});
|
|
762
|
+
const audioUrl = audioFinal.response ?? "";
|
|
763
|
+
if (audioFinal.status !== "completed" || !audioUrl) {
|
|
764
|
+
throw new Error(`Audio generation failed for avatar ${avatar_id}: status=${audioFinal.status} message=${audioFinal.status_message ?? "(none)"}`);
|
|
765
|
+
}
|
|
766
|
+
const videoInitial = await client2.generateVideo({
|
|
767
|
+
type: "video",
|
|
768
|
+
q: script,
|
|
769
|
+
audio_url: audioUrl,
|
|
770
|
+
image_url: imageUrl,
|
|
771
|
+
aspect_ratio: aspect_ratio ?? "9:16",
|
|
772
|
+
driver: driver ?? "fal",
|
|
773
|
+
// Empirically verified default: veed_fabric_1.0 is what Followr uses
|
|
774
|
+
// in production for avatar lipsync renders (workspace 8 historical
|
|
775
|
+
// aiResults with type=video on 2026-05-13).
|
|
776
|
+
model: model ?? "veed_fabric_1.0",
|
|
777
|
+
company_id,
|
|
778
|
+
chargeable: 1
|
|
779
|
+
});
|
|
780
|
+
const videoFinal = await client2.waitForAiResult(videoInitial.id, {
|
|
781
|
+
timeoutMs: (timeout_seconds ?? 600) * 1e3
|
|
782
|
+
});
|
|
783
|
+
return {
|
|
784
|
+
content: [
|
|
785
|
+
{
|
|
786
|
+
type: "text",
|
|
787
|
+
text: JSON.stringify({
|
|
788
|
+
avatar_id,
|
|
789
|
+
audio_ai_result_id: audioFinal.id,
|
|
790
|
+
audio_url: audioUrl,
|
|
791
|
+
image_url: imageUrl,
|
|
792
|
+
video: sanitizeAiResult(videoFinal)
|
|
793
|
+
}, null, 2)
|
|
794
|
+
}
|
|
795
|
+
]
|
|
796
|
+
};
|
|
797
|
+
});
|
|
798
|
+
server2.registerTool("list_ai_results", {
|
|
799
|
+
title: "List past AI generations in a workspace",
|
|
800
|
+
description: "List previously generated AI results in a workspace, filtered by type and optionally by model. Use this to recover prior generations and reference their URLs without paying credits to regenerate. Sorted newest first by default.",
|
|
801
|
+
inputSchema: {
|
|
802
|
+
company_id: z.number().int().positive(),
|
|
803
|
+
type: z.enum(["chat", "image", "audio", "video"]).optional().describe("Filter by generation type. Omit for all types."),
|
|
804
|
+
model: z.string().optional().describe("Filter by exact model id. Useful for Viral Shorts (creatomate_short)."),
|
|
805
|
+
include: z.string().optional().describe("Comma-separated includes. e.g. 'image,image.thumbnail' for images, 'videos,videos.thumbnail' for videos."),
|
|
806
|
+
page_size: z.number().int().positive().max(100).optional(),
|
|
807
|
+
sort: z.string().optional().describe("Default -created_at.")
|
|
808
|
+
}
|
|
809
|
+
}, async ({ company_id, type, model, include, page_size, sort }) => {
|
|
810
|
+
const results = await client2.listAiResults({
|
|
811
|
+
companyId: company_id,
|
|
812
|
+
...type ? { type } : {},
|
|
813
|
+
...model ? { model } : {},
|
|
814
|
+
...include ? { include } : {},
|
|
815
|
+
...page_size ? { pageSize: page_size } : {},
|
|
816
|
+
...sort ? { sort } : {}
|
|
817
|
+
});
|
|
818
|
+
return {
|
|
819
|
+
content: [
|
|
820
|
+
{
|
|
821
|
+
type: "text",
|
|
822
|
+
text: JSON.stringify(results.map(sanitizeAiResult), null, 2)
|
|
823
|
+
}
|
|
824
|
+
]
|
|
825
|
+
};
|
|
826
|
+
});
|
|
827
|
+
server2.registerTool("get_ai_result", {
|
|
828
|
+
title: "Get a single AI result by id (no polling)",
|
|
829
|
+
description: "Fetch a single aiResult by id. Use this for a cheap status check on an async job. For automatic polling until terminal state, use wait_for_ai_result instead.",
|
|
830
|
+
inputSchema: {
|
|
831
|
+
ai_result_id: z.number().int().positive()
|
|
832
|
+
}
|
|
833
|
+
}, async ({ ai_result_id }) => {
|
|
834
|
+
const result = await client2.getAiResult(ai_result_id);
|
|
835
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(result), null, 2) }] };
|
|
836
|
+
});
|
|
837
|
+
server2.registerTool("wait_for_ai_result", {
|
|
838
|
+
title: "Wait for an AI result to complete (polling helper)",
|
|
839
|
+
description: "Poll an aiResult by id until its status is terminal (completed or failed) or the timeout elapses. Use when you have an id from a previous generate_* call with wait=false and want to block until done.",
|
|
840
|
+
inputSchema: {
|
|
841
|
+
ai_result_id: z.number().int().positive(),
|
|
842
|
+
timeout_seconds: z.number().int().positive().max(900).optional().describe("Max seconds to wait. Default 300."),
|
|
843
|
+
interval_seconds: z.number().min(1).max(30).optional().describe("Polling interval in seconds. Default 2.5.")
|
|
844
|
+
}
|
|
845
|
+
}, async ({ ai_result_id, timeout_seconds, interval_seconds }) => {
|
|
846
|
+
const result = await client2.waitForAiResult(ai_result_id, {
|
|
847
|
+
timeoutMs: (timeout_seconds ?? 300) * 1e3,
|
|
848
|
+
intervalMs: (interval_seconds ?? 2.5) * 1e3
|
|
849
|
+
});
|
|
850
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAiResult(result), null, 2) }] };
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// ../mcp-core/dist/tools/analytics.js
|
|
855
|
+
import { z as z2 } from "zod";
|
|
856
|
+
function readNumber(record, field) {
|
|
857
|
+
if (record && typeof record === "object" && field in record) {
|
|
858
|
+
const value = record[field];
|
|
859
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
860
|
+
return value;
|
|
861
|
+
if (typeof value === "string" && value.trim() !== "" && !Number.isNaN(Number(value))) {
|
|
862
|
+
return Number(value);
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return null;
|
|
866
|
+
}
|
|
867
|
+
function registerAnalyticsTools(server2, client2, _options) {
|
|
868
|
+
server2.registerTool("get_post_analytics", {
|
|
869
|
+
title: "Get per-post metrics for a specific connected social account",
|
|
870
|
+
description: "Return per-post analytics for a single connected social network account in a date range. Caller specifies which fields to bring back (reach, impressions, likes, comments, shares, etc.) via the `fields` param. Use get_workspace_summary if you want a roll-up across all connected accounts in one call.",
|
|
871
|
+
inputSchema: {
|
|
872
|
+
social_network_id: z2.number().int().positive().describe("Connected account id (use list_companies + workspace settings to discover, or list_social_networks once added)."),
|
|
873
|
+
since: z2.string().describe("ISO 8601 lower bound (inclusive)."),
|
|
874
|
+
until: z2.string().describe("ISO 8601 upper bound (inclusive)."),
|
|
875
|
+
fields: z2.string().optional().describe("Comma-separated list of metric fields to include. Provider-specific. Omit for default set."),
|
|
876
|
+
limit: z2.number().int().positive().max(500).optional()
|
|
877
|
+
}
|
|
878
|
+
}, async ({ social_network_id, since, until, fields, limit }) => {
|
|
879
|
+
const metrics = await client2.listSocialNetworkPostMetrics(social_network_id, {
|
|
880
|
+
since,
|
|
881
|
+
until,
|
|
882
|
+
...fields ? { fields } : {},
|
|
883
|
+
...limit ? { limit } : {}
|
|
884
|
+
});
|
|
885
|
+
return { content: [{ type: "text", text: JSON.stringify(metrics, null, 2) }] };
|
|
886
|
+
});
|
|
887
|
+
server2.registerTool("get_workspace_summary", {
|
|
888
|
+
title: "Aggregate analytics across every connected account in a workspace",
|
|
889
|
+
description: "Iterate over all connected social networks in a workspace and pull per-post metrics for each in the given date range. Returns a map keyed by social network id. Heavy call: makes N requests (one per connected account). Useful for end-of-period reports.",
|
|
890
|
+
inputSchema: {
|
|
891
|
+
company_id: z2.number().int().positive(),
|
|
892
|
+
since: z2.string().describe("ISO 8601 lower bound."),
|
|
893
|
+
until: z2.string().describe("ISO 8601 upper bound."),
|
|
894
|
+
fields: z2.string().optional().describe("Comma-separated metric fields. Provider-specific."),
|
|
895
|
+
limit_per_network: z2.number().int().positive().max(500).optional()
|
|
896
|
+
}
|
|
897
|
+
}, async ({ company_id, since, until, fields, limit_per_network }) => {
|
|
898
|
+
const networks = await client2.listSocialNetworks(company_id);
|
|
899
|
+
const perNetwork = await Promise.all(networks.map(async (n) => {
|
|
900
|
+
try {
|
|
901
|
+
const metrics = await client2.listSocialNetworkPostMetrics(n.id, {
|
|
902
|
+
since,
|
|
903
|
+
until,
|
|
904
|
+
...fields ? { fields } : {},
|
|
905
|
+
...limit_per_network ? { limit: limit_per_network } : {}
|
|
906
|
+
});
|
|
907
|
+
return {
|
|
908
|
+
social_network_id: n.id,
|
|
909
|
+
type: n.type ?? null,
|
|
910
|
+
status: n.status ?? null,
|
|
911
|
+
post_count: metrics.length,
|
|
912
|
+
metrics
|
|
913
|
+
};
|
|
914
|
+
} catch (err) {
|
|
915
|
+
return {
|
|
916
|
+
social_network_id: n.id,
|
|
917
|
+
type: n.type ?? null,
|
|
918
|
+
status: n.status ?? null,
|
|
919
|
+
post_count: 0,
|
|
920
|
+
error: err instanceof Error ? err.message : String(err),
|
|
921
|
+
metrics: []
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
}));
|
|
925
|
+
return { content: [{ type: "text", text: JSON.stringify(perNetwork, null, 2) }] };
|
|
926
|
+
});
|
|
927
|
+
server2.registerTool("get_best_performing_posts", {
|
|
928
|
+
title: "Find the top performing posts across a workspace by a chosen metric",
|
|
929
|
+
description: "Pull per-post analytics for every connected account in a workspace, then flatten and sort by a numeric metric field of your choice (e.g. reach, impressions, engagement). Optionally filter to a single network type. Returns the top N posts.",
|
|
930
|
+
inputSchema: {
|
|
931
|
+
company_id: z2.number().int().positive(),
|
|
932
|
+
since: z2.string().describe("ISO 8601 lower bound."),
|
|
933
|
+
until: z2.string().describe("ISO 8601 upper bound."),
|
|
934
|
+
sort_by: z2.string().describe("Numeric metric field to sort by (provider-specific). Common: reach, impressions, likes, engagement."),
|
|
935
|
+
network_type: z2.string().optional().describe("Optional filter to a single network type (instagram, facebook, etc.)."),
|
|
936
|
+
top_n: z2.number().int().positive().max(100).optional().describe("Number of top posts to return. Default 10."),
|
|
937
|
+
fields: z2.string().optional().describe("Comma-separated metric fields to bring back (must include sort_by).")
|
|
938
|
+
}
|
|
939
|
+
}, async ({ company_id, since, until, sort_by, network_type, top_n, fields }) => {
|
|
940
|
+
const networks = await client2.listSocialNetworks(company_id);
|
|
941
|
+
const filtered = network_type ? networks.filter((n) => n.type === network_type) : networks;
|
|
942
|
+
const all = [];
|
|
943
|
+
await Promise.all(filtered.map(async (n) => {
|
|
944
|
+
try {
|
|
945
|
+
const metrics = await client2.listSocialNetworkPostMetrics(n.id, {
|
|
946
|
+
since,
|
|
947
|
+
until,
|
|
948
|
+
...fields ? { fields } : {}
|
|
949
|
+
});
|
|
950
|
+
for (const post of metrics) {
|
|
951
|
+
all.push({
|
|
952
|
+
social_network_id: n.id,
|
|
953
|
+
type: n.type ?? null,
|
|
954
|
+
post,
|
|
955
|
+
sort_value: readNumber(post, sort_by)
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
} catch {
|
|
959
|
+
}
|
|
960
|
+
}));
|
|
961
|
+
all.sort((a, b) => {
|
|
962
|
+
if (a.sort_value === null && b.sort_value === null)
|
|
963
|
+
return 0;
|
|
964
|
+
if (a.sort_value === null)
|
|
965
|
+
return 1;
|
|
966
|
+
if (b.sort_value === null)
|
|
967
|
+
return -1;
|
|
968
|
+
return b.sort_value - a.sort_value;
|
|
969
|
+
});
|
|
970
|
+
const top = all.slice(0, top_n ?? 10);
|
|
971
|
+
return { content: [{ type: "text", text: JSON.stringify(top, null, 2) }] };
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ../mcp-core/dist/tools/assets.js
|
|
976
|
+
import { z as z3 } from "zod";
|
|
977
|
+
function filenameFromUrl(url, fallbackExt) {
|
|
978
|
+
try {
|
|
979
|
+
const u = new URL(url);
|
|
980
|
+
const last = u.pathname.split("/").filter(Boolean).pop();
|
|
981
|
+
if (last && last.includes("."))
|
|
982
|
+
return last;
|
|
983
|
+
return `asset-${Date.now()}.${fallbackExt}`;
|
|
984
|
+
} catch {
|
|
985
|
+
return `asset-${Date.now()}.${fallbackExt}`;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
async function uploadFromUrl(client2, args) {
|
|
989
|
+
const { companyId, url, type, name, visibility, contentTypeOverride } = args;
|
|
990
|
+
const downloadResp = await fetch(url);
|
|
991
|
+
if (!downloadResp.ok) {
|
|
992
|
+
throw new Error(`Failed to download ${type} from URL: ${downloadResp.status} ${downloadResp.statusText}`);
|
|
993
|
+
}
|
|
994
|
+
const contentType = contentTypeOverride ?? downloadResp.headers.get("content-type") ?? (type === "image" ? "image/jpeg" : "video/mp4");
|
|
995
|
+
const buffer = await downloadResp.arrayBuffer();
|
|
996
|
+
const fallbackExt = type === "image" ? "jpg" : "mp4";
|
|
997
|
+
const filename = name ?? filenameFromUrl(url, fallbackExt);
|
|
998
|
+
const asset = await client2.createAsset(companyId, { name: filename, type });
|
|
999
|
+
const upload = await client2.requestAssetUpload(asset.id, type, {
|
|
1000
|
+
filename,
|
|
1001
|
+
type,
|
|
1002
|
+
visibility: visibility ?? "public"
|
|
1003
|
+
});
|
|
1004
|
+
await client2.uploadToBlob(upload.presigned_url, buffer, contentType);
|
|
1005
|
+
return { ...asset, url: upload.url };
|
|
1006
|
+
}
|
|
1007
|
+
function registerAssetTools(server2, client2, _options) {
|
|
1008
|
+
server2.registerTool("upload_image_from_url", {
|
|
1009
|
+
title: "Upload an image to a workspace from a public URL",
|
|
1010
|
+
description: "Download an image from a public URL and upload it to the workspace's asset library via the 3-step pattern (create asset placeholder, request presigned URL, PUT binary). Returns the final Asset with `url` ready to attach to a Post. Caller must ensure the URL is reachable and serves the image bytes (not an HTML page).",
|
|
1011
|
+
inputSchema: {
|
|
1012
|
+
company_id: z3.number().int().positive(),
|
|
1013
|
+
url: z3.string().url().describe("Public URL of the image to ingest."),
|
|
1014
|
+
name: z3.string().optional().describe("Optional filename. Defaults to the URL's last path segment."),
|
|
1015
|
+
visibility: z3.enum(["public", "private"]).optional().describe("Default public.")
|
|
1016
|
+
}
|
|
1017
|
+
}, async ({ company_id, url, name, visibility }) => {
|
|
1018
|
+
const asset = await uploadFromUrl(client2, {
|
|
1019
|
+
companyId: company_id,
|
|
1020
|
+
url,
|
|
1021
|
+
type: "image",
|
|
1022
|
+
...name ? { name } : {},
|
|
1023
|
+
...visibility ? { visibility } : {}
|
|
1024
|
+
});
|
|
1025
|
+
return { content: [{ type: "text", text: JSON.stringify(asset, null, 2) }] };
|
|
1026
|
+
});
|
|
1027
|
+
server2.registerTool("upload_video_from_url", {
|
|
1028
|
+
title: "Upload a video to a workspace from a public URL",
|
|
1029
|
+
description: "Download a video from a public URL and upload it to the workspace's asset library via the 3-step pattern. Returns the final Asset with `url` ready to attach to a Post (Reel, Story, TikTok, etc.). Caller must ensure the URL is reachable and serves the video bytes directly.",
|
|
1030
|
+
inputSchema: {
|
|
1031
|
+
company_id: z3.number().int().positive(),
|
|
1032
|
+
url: z3.string().url().describe("Public URL of the video to ingest."),
|
|
1033
|
+
name: z3.string().optional(),
|
|
1034
|
+
visibility: z3.enum(["public", "private"]).optional()
|
|
1035
|
+
}
|
|
1036
|
+
}, async ({ company_id, url, name, visibility }) => {
|
|
1037
|
+
const asset = await uploadFromUrl(client2, {
|
|
1038
|
+
companyId: company_id,
|
|
1039
|
+
url,
|
|
1040
|
+
type: "video",
|
|
1041
|
+
...name ? { name } : {},
|
|
1042
|
+
...visibility ? { visibility } : {}
|
|
1043
|
+
});
|
|
1044
|
+
return { content: [{ type: "text", text: JSON.stringify(asset, null, 2) }] };
|
|
1045
|
+
});
|
|
1046
|
+
server2.registerTool("list_assets", {
|
|
1047
|
+
title: "List assets in a workspace",
|
|
1048
|
+
description: "List assets (uploaded images and videos) in a workspace, optionally filtered by type and folder. Use to discover existing assets before deciding to re-upload.",
|
|
1049
|
+
inputSchema: {
|
|
1050
|
+
company_id: z3.number().int().positive(),
|
|
1051
|
+
type: z3.enum(["image", "video", "audio"]).optional().describe("Filter by type. Omit for all."),
|
|
1052
|
+
folder_id: z3.number().int().positive().optional(),
|
|
1053
|
+
in_root_only: z3.boolean().optional().describe("If true, only return assets at the workspace root (folder_id is null). Ignored if folder_id is set."),
|
|
1054
|
+
page_size: z3.number().int().positive().max(100).optional()
|
|
1055
|
+
}
|
|
1056
|
+
}, async ({ company_id, type, folder_id, in_root_only, page_size }) => {
|
|
1057
|
+
const folderArg = folder_id !== void 0 ? folder_id : in_root_only ? null : void 0;
|
|
1058
|
+
const assets = await client2.listAssets(company_id, {
|
|
1059
|
+
...type ? { type } : {},
|
|
1060
|
+
...folderArg !== void 0 ? { folderId: folderArg } : {},
|
|
1061
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1062
|
+
});
|
|
1063
|
+
return {
|
|
1064
|
+
content: [
|
|
1065
|
+
{
|
|
1066
|
+
type: "text",
|
|
1067
|
+
text: JSON.stringify(assets.map((a) => ({
|
|
1068
|
+
id: a.id,
|
|
1069
|
+
name: a.name,
|
|
1070
|
+
type: a.type,
|
|
1071
|
+
url: a.url,
|
|
1072
|
+
extension: a.extension,
|
|
1073
|
+
width: a.width,
|
|
1074
|
+
height: a.height,
|
|
1075
|
+
duration: a.duration,
|
|
1076
|
+
size: a.size,
|
|
1077
|
+
visibility: a.visibility
|
|
1078
|
+
})), null, 2)
|
|
1079
|
+
}
|
|
1080
|
+
]
|
|
1081
|
+
};
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ../mcp-core/dist/tools/avatars.js
|
|
1086
|
+
import { z as z4 } from "zod";
|
|
1087
|
+
function sanitizeAvatar(avatar) {
|
|
1088
|
+
const av = avatar;
|
|
1089
|
+
if (av.company) {
|
|
1090
|
+
const { ai_keys: _ak, webhook_secret: _ws, ...safeCompany } = av.company;
|
|
1091
|
+
av.company = safeCompany;
|
|
1092
|
+
}
|
|
1093
|
+
return av;
|
|
1094
|
+
}
|
|
1095
|
+
var DEFAULT_INCLUDE = "image,image.thumbnail,voice,voice.audio,scenes,scenes.thumbnail";
|
|
1096
|
+
function registerAvatarTools(server2, client2, _options) {
|
|
1097
|
+
server2.registerTool("list_avatars", {
|
|
1098
|
+
title: "List avatars in a workspace",
|
|
1099
|
+
description: "List avatars belonging to a Followr workspace, each hydrated with its image (with thumbnail), voice (with audio sample), and scenes. Use this to discover available avatars before generating an avatar video.",
|
|
1100
|
+
inputSchema: {
|
|
1101
|
+
company_id: z4.number().int().positive(),
|
|
1102
|
+
page_size: z4.number().int().positive().max(100).optional()
|
|
1103
|
+
}
|
|
1104
|
+
}, async ({ company_id, page_size }) => {
|
|
1105
|
+
const avatars = await client2.listAvatars(company_id, {
|
|
1106
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1107
|
+
});
|
|
1108
|
+
return {
|
|
1109
|
+
content: [
|
|
1110
|
+
{
|
|
1111
|
+
type: "text",
|
|
1112
|
+
text: JSON.stringify(avatars.map(sanitizeAvatar), null, 2)
|
|
1113
|
+
}
|
|
1114
|
+
]
|
|
1115
|
+
};
|
|
1116
|
+
});
|
|
1117
|
+
server2.registerTool("get_avatar", {
|
|
1118
|
+
title: "Get a single avatar with image, voice, and scenes",
|
|
1119
|
+
description: "Fetch one avatar by id, hydrated by default with image, voice (with audio sample), and scenes. Use this to inspect an avatar's resources or to confirm a freshly created avatar is ready.",
|
|
1120
|
+
inputSchema: {
|
|
1121
|
+
avatar_id: z4.number().int().positive(),
|
|
1122
|
+
include: z4.string().optional().describe("Override the include chain. Default hydrates image, voice, and scenes.")
|
|
1123
|
+
}
|
|
1124
|
+
}, async ({ avatar_id, include }) => {
|
|
1125
|
+
const avatar = await client2.getAvatar(avatar_id, {
|
|
1126
|
+
include: include ?? DEFAULT_INCLUDE
|
|
1127
|
+
});
|
|
1128
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAvatar(avatar), null, 2) }] };
|
|
1129
|
+
});
|
|
1130
|
+
server2.registerTool("create_avatar_full_flow", {
|
|
1131
|
+
title: "Create an avatar end-to-end (image gen + resource + upload)",
|
|
1132
|
+
description: "Workflow that creates a custom avatar from a single prompt. Steps internally: 1) generates an image with Followr AI from the prompt, 2) creates the avatar resource linked to the given voice_id, 3) attaches the generated image to the avatar via the 3-step upload pattern. Requires voice_id (use create_voice_from_elevenlabs first if no suitable voice exists). Costs around 25 credits for the image generation. Cannot be undone (the avatar resource persists; delete manually if needed).",
|
|
1133
|
+
inputSchema: {
|
|
1134
|
+
company_id: z4.number().int().positive(),
|
|
1135
|
+
prompt: z4.string().min(1).describe("Visual prompt describing the avatar (e.g. 'professional female news anchor in studio')."),
|
|
1136
|
+
voice_id: z4.number().int().positive().describe("Existing Voice.id from list_voices or a voice freshly created via create_voice_from_elevenlabs."),
|
|
1137
|
+
name: z4.string().min(1).max(50).describe("Display name for the avatar."),
|
|
1138
|
+
description: z4.string().optional().describe("Optional description. Defaults to a truncated form of the prompt."),
|
|
1139
|
+
aspect_ratio: z4.enum(["1:1", "9:16", "16:9", "4:5"]).optional().describe("Aspect ratio of the generated portrait. Default 1:1."),
|
|
1140
|
+
default: z4.boolean().optional().describe("If true, marks this avatar as the workspace default. Default false."),
|
|
1141
|
+
image_driver: z4.string().optional().describe("Optional image generation driver override. e.g. fal, recraft, openai."),
|
|
1142
|
+
image_model: z4.string().optional().describe("Optional image model override. e.g. nano_banana_2."),
|
|
1143
|
+
timeout_seconds: z4.number().int().positive().max(900).optional().describe("Max seconds for image generation to complete. Default 300.")
|
|
1144
|
+
}
|
|
1145
|
+
}, async ({ company_id, prompt, voice_id, name, description, aspect_ratio, default: isDefault, image_driver, image_model, timeout_seconds }) => {
|
|
1146
|
+
const initialImage = await client2.generateImage({
|
|
1147
|
+
q: prompt,
|
|
1148
|
+
company_id,
|
|
1149
|
+
aspect_ratio: aspect_ratio ?? "1:1",
|
|
1150
|
+
n: 1,
|
|
1151
|
+
chargeable: 1,
|
|
1152
|
+
queue: true,
|
|
1153
|
+
// Defaults verified empirically (workspace ai_preferences are NOT
|
|
1154
|
+
// automatically applied when driver/model are omitted from the body).
|
|
1155
|
+
driver: image_driver ?? "fal",
|
|
1156
|
+
model: image_model ?? "nano_banana_2"
|
|
1157
|
+
});
|
|
1158
|
+
const completedImage = await client2.waitForAiResult(initialImage.id, {
|
|
1159
|
+
timeoutMs: (timeout_seconds ?? 300) * 1e3
|
|
1160
|
+
});
|
|
1161
|
+
const generatedImageUrl = completedImage.response ?? "";
|
|
1162
|
+
if (completedImage.status !== "completed" || !generatedImageUrl) {
|
|
1163
|
+
throw new Error(`Avatar image generation failed: status=${completedImage.status} message=${completedImage.status_message ?? "(none)"}`);
|
|
1164
|
+
}
|
|
1165
|
+
const avatar = await client2.createAvatar(company_id, {
|
|
1166
|
+
name,
|
|
1167
|
+
description: description ?? prompt.slice(0, 340),
|
|
1168
|
+
voice_id,
|
|
1169
|
+
default: isDefault ?? false
|
|
1170
|
+
});
|
|
1171
|
+
const filename = `avatar-${avatar.id}-${Date.now()}.jpg`;
|
|
1172
|
+
const uploadInfo = await client2.requestAvatarImageUpload(avatar.id, {
|
|
1173
|
+
filename,
|
|
1174
|
+
type: "image",
|
|
1175
|
+
visibility: "public"
|
|
1176
|
+
});
|
|
1177
|
+
const downloadResp = await fetch(generatedImageUrl);
|
|
1178
|
+
if (!downloadResp.ok) {
|
|
1179
|
+
throw new Error(`Failed to download generated image from CDN: ${downloadResp.status} ${downloadResp.statusText}`);
|
|
1180
|
+
}
|
|
1181
|
+
const buffer = await downloadResp.arrayBuffer();
|
|
1182
|
+
await client2.uploadToBlob(uploadInfo.presigned_url, buffer, "image/jpeg");
|
|
1183
|
+
const finalAvatar = await client2.getAvatar(avatar.id, { include: DEFAULT_INCLUDE });
|
|
1184
|
+
return {
|
|
1185
|
+
content: [
|
|
1186
|
+
{
|
|
1187
|
+
type: "text",
|
|
1188
|
+
text: JSON.stringify({
|
|
1189
|
+
image_ai_result_id: completedImage.id,
|
|
1190
|
+
source_image_url: generatedImageUrl,
|
|
1191
|
+
avatar: sanitizeAvatar(finalAvatar)
|
|
1192
|
+
}, null, 2)
|
|
1193
|
+
}
|
|
1194
|
+
]
|
|
1195
|
+
};
|
|
1196
|
+
});
|
|
1197
|
+
server2.registerTool("update_avatar", {
|
|
1198
|
+
title: "Update an avatar's metadata",
|
|
1199
|
+
description: "Patch an avatar's name, description, default flag, or voice_id. Useful for renaming, swapping the assigned voice, or marking another avatar as the workspace default. Does NOT change the avatar's image (re-upload via the create flow if image change is needed).",
|
|
1200
|
+
inputSchema: {
|
|
1201
|
+
avatar_id: z4.number().int().positive(),
|
|
1202
|
+
name: z4.string().min(1).max(50).optional(),
|
|
1203
|
+
description: z4.string().optional(),
|
|
1204
|
+
default: z4.boolean().optional(),
|
|
1205
|
+
voice_id: z4.number().int().positive().optional()
|
|
1206
|
+
}
|
|
1207
|
+
}, async ({ avatar_id, ...patch }) => {
|
|
1208
|
+
const updated = await client2.updateAvatar(avatar_id, patch);
|
|
1209
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeAvatar(updated), null, 2) }] };
|
|
1210
|
+
});
|
|
1211
|
+
server2.registerTool("list_avatar_scenes", {
|
|
1212
|
+
title: "List scenes attached to an avatar",
|
|
1213
|
+
description: "Return the scenes (video clips) associated with an avatar. Internally fetches the avatar with the scenes include chain and returns just the scenes array. Use this to inspect what motion clips are available for combining into an avatar video.",
|
|
1214
|
+
inputSchema: {
|
|
1215
|
+
avatar_id: z4.number().int().positive()
|
|
1216
|
+
}
|
|
1217
|
+
}, async ({ avatar_id }) => {
|
|
1218
|
+
const avatar = await client2.getAvatar(avatar_id, { include: "scenes,scenes.thumbnail" });
|
|
1219
|
+
return {
|
|
1220
|
+
content: [
|
|
1221
|
+
{
|
|
1222
|
+
type: "text",
|
|
1223
|
+
text: JSON.stringify(avatar.scenes ?? [], null, 2)
|
|
1224
|
+
}
|
|
1225
|
+
]
|
|
1226
|
+
};
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// ../mcp-core/dist/tools/canva.js
|
|
1231
|
+
import { z as z5 } from "zod";
|
|
1232
|
+
async function pollExportJob(client2, companyId, jobId, options) {
|
|
1233
|
+
const start = Date.now();
|
|
1234
|
+
while (Date.now() - start < options.timeoutMs) {
|
|
1235
|
+
const raw = await client2.getCanvaDesignExportJob(companyId, jobId);
|
|
1236
|
+
const status = raw.status;
|
|
1237
|
+
if (status === "completed" || status === "failed")
|
|
1238
|
+
return raw;
|
|
1239
|
+
await new Promise((resolve) => setTimeout(resolve, options.intervalMs));
|
|
1240
|
+
}
|
|
1241
|
+
throw new Error(`Canva export job ${jobId} timed out after ${options.timeoutMs}ms`);
|
|
1242
|
+
}
|
|
1243
|
+
function urlsFromExportJob(job) {
|
|
1244
|
+
if (Array.isArray(job.urls) && job.urls.length > 0)
|
|
1245
|
+
return job.urls;
|
|
1246
|
+
if (typeof job.url === "string" && job.url)
|
|
1247
|
+
return [job.url];
|
|
1248
|
+
return [];
|
|
1249
|
+
}
|
|
1250
|
+
async function uploadExportedPageAsAsset(client2, companyId, pageUrl, pageIndex, designId, type) {
|
|
1251
|
+
const downloadResp = await fetch(pageUrl);
|
|
1252
|
+
if (!downloadResp.ok) {
|
|
1253
|
+
throw new Error(`Failed to download Canva exported page ${pageIndex}: ${downloadResp.status}`);
|
|
1254
|
+
}
|
|
1255
|
+
const contentType = downloadResp.headers.get("content-type") ?? (type === "image" ? "image/jpeg" : "video/mp4");
|
|
1256
|
+
const buffer = await downloadResp.arrayBuffer();
|
|
1257
|
+
const ext = type === "image" ? "jpg" : "mp4";
|
|
1258
|
+
const filename = `canva-${designId}-page-${pageIndex + 1}.${ext}`;
|
|
1259
|
+
const asset = await client2.createAsset(companyId, { name: filename, type });
|
|
1260
|
+
const upload = await client2.requestAssetUpload(asset.id, type, {
|
|
1261
|
+
filename,
|
|
1262
|
+
type,
|
|
1263
|
+
visibility: "public"
|
|
1264
|
+
});
|
|
1265
|
+
await client2.uploadToBlob(upload.presigned_url, buffer, contentType);
|
|
1266
|
+
return asset.id;
|
|
1267
|
+
}
|
|
1268
|
+
function registerCanvaTools(server2, client2, _options) {
|
|
1269
|
+
server2.registerTool("list_canva_designs", {
|
|
1270
|
+
title: "List Canva designs available to the workspace",
|
|
1271
|
+
description: "List Canva designs that the workspace's connected Canva account can access. Supports title substring filter via `search`. Returns each design's id, title, page count, thumbnail, and Canva edit / view URLs. Requires Canva OAuth to be connected for this workspace.",
|
|
1272
|
+
inputSchema: {
|
|
1273
|
+
company_id: z5.number().int().positive(),
|
|
1274
|
+
search: z5.string().optional().describe("Substring match on design title."),
|
|
1275
|
+
limit: z5.number().int().positive().max(100).optional(),
|
|
1276
|
+
continuation_token: z5.string().optional().describe("Cursor for next page (from prior response).")
|
|
1277
|
+
}
|
|
1278
|
+
}, async ({ company_id, search, limit, continuation_token }) => {
|
|
1279
|
+
const designs = await client2.listCanvaDesigns(company_id, {
|
|
1280
|
+
...search ? { search } : {},
|
|
1281
|
+
...limit ? { limit } : {},
|
|
1282
|
+
...continuation_token ? { continuationToken: continuation_token } : {}
|
|
1283
|
+
});
|
|
1284
|
+
return {
|
|
1285
|
+
content: [
|
|
1286
|
+
{
|
|
1287
|
+
type: "text",
|
|
1288
|
+
text: JSON.stringify(designs.map((d) => ({
|
|
1289
|
+
id: d.id,
|
|
1290
|
+
title: d.title,
|
|
1291
|
+
page_count: d.page_count,
|
|
1292
|
+
thumbnail_url: d.thumbnail?.url,
|
|
1293
|
+
edit_url: d.urls?.edit_url,
|
|
1294
|
+
view_url: d.urls?.view_url,
|
|
1295
|
+
updated_at: d.updated_at
|
|
1296
|
+
})), null, 2)
|
|
1297
|
+
}
|
|
1298
|
+
]
|
|
1299
|
+
};
|
|
1300
|
+
});
|
|
1301
|
+
server2.registerTool("export_canva_design", {
|
|
1302
|
+
title: "Export a Canva design and return the downloadable URLs",
|
|
1303
|
+
description: "Start a Canva export job for a design and poll until it completes, returning the per-page downloadable URLs. Use this when you want the raw exported assets without immediately turning them into a post (use import_canva_design_as_post for the all-in-one workflow). One URL per page of the design.",
|
|
1304
|
+
inputSchema: {
|
|
1305
|
+
company_id: z5.number().int().positive(),
|
|
1306
|
+
design_id: z5.string().min(1).describe("Canva design id (from list_canva_designs)."),
|
|
1307
|
+
type: z5.enum(["jpg", "png", "pdf", "gif", "mp4"]).optional().describe("Export format. Default jpg."),
|
|
1308
|
+
quality: z5.string().optional().describe("Quality preset. Common: regular, high_quality. Provider-specific."),
|
|
1309
|
+
timeout_seconds: z5.number().int().positive().max(300).optional().describe("Max seconds to poll. Default 120.")
|
|
1310
|
+
}
|
|
1311
|
+
}, async ({ company_id, design_id, type, quality, timeout_seconds }) => {
|
|
1312
|
+
const start = await client2.startCanvaDesignExport(company_id, {
|
|
1313
|
+
design_id,
|
|
1314
|
+
format: { type: type ?? "jpg", quality: quality ?? "regular" }
|
|
1315
|
+
});
|
|
1316
|
+
const job = await pollExportJob(client2, company_id, start.job_id, {
|
|
1317
|
+
timeoutMs: (timeout_seconds ?? 120) * 1e3,
|
|
1318
|
+
intervalMs: 2500
|
|
1319
|
+
});
|
|
1320
|
+
if (job.status !== "completed") {
|
|
1321
|
+
throw new Error(`Canva export job failed: status=${job.status} error=${JSON.stringify(job.error)}`);
|
|
1322
|
+
}
|
|
1323
|
+
const urls = urlsFromExportJob(job);
|
|
1324
|
+
return {
|
|
1325
|
+
content: [
|
|
1326
|
+
{
|
|
1327
|
+
type: "text",
|
|
1328
|
+
text: JSON.stringify({ job_id: start.job_id, status: job.status, page_count: urls.length, urls }, null, 2)
|
|
1329
|
+
}
|
|
1330
|
+
]
|
|
1331
|
+
};
|
|
1332
|
+
});
|
|
1333
|
+
server2.registerTool("import_canva_design_as_post", {
|
|
1334
|
+
title: "Export a Canva design and create a scheduled or draft post from it (MEGA workflow)",
|
|
1335
|
+
description: "End-to-end workflow: 1) export a Canva design, 2) wait for completion, 3) upload each exported page as a Followr asset, 4) create a PostGroup, 5) create one Post per requested social network attaching the uploaded asset ids (multi-page designs become carousels). If publish_at is omitted, the PostGroup is created as a draft. Replaces ~6 manual UI clicks. Use to programmatically syndicate a Canva design to multiple networks in one shot.",
|
|
1336
|
+
inputSchema: {
|
|
1337
|
+
company_id: z5.number().int().positive(),
|
|
1338
|
+
design_id: z5.string().min(1),
|
|
1339
|
+
social_networks: z5.array(z5.string()).min(1).describe("Network types to publish to. e.g. ['instagram', 'facebook', 'linkedin']."),
|
|
1340
|
+
description: z5.string().optional().describe("Post copy (same for all networks). Use update_post_group later for per-network tweaks."),
|
|
1341
|
+
title: z5.string().optional(),
|
|
1342
|
+
publish_at: z5.string().optional().describe("ISO 8601 UTC datetime. If omitted, the PostGroup is created as draft."),
|
|
1343
|
+
export_type: z5.enum(["jpg", "png", "mp4"]).optional().describe("Export format. Default jpg. Use mp4 for video designs."),
|
|
1344
|
+
quality: z5.string().optional(),
|
|
1345
|
+
timeout_seconds: z5.number().int().positive().max(300).optional()
|
|
1346
|
+
}
|
|
1347
|
+
}, async ({ company_id, design_id, social_networks, description, title, publish_at, export_type, quality, timeout_seconds }) => {
|
|
1348
|
+
const exportType = export_type ?? "jpg";
|
|
1349
|
+
const start = await client2.startCanvaDesignExport(company_id, {
|
|
1350
|
+
design_id,
|
|
1351
|
+
format: { type: exportType, quality: quality ?? "regular" }
|
|
1352
|
+
});
|
|
1353
|
+
const job = await pollExportJob(client2, company_id, start.job_id, {
|
|
1354
|
+
timeoutMs: (timeout_seconds ?? 180) * 1e3,
|
|
1355
|
+
intervalMs: 2500
|
|
1356
|
+
});
|
|
1357
|
+
if (job.status !== "completed") {
|
|
1358
|
+
throw new Error(`Canva export job failed: status=${job.status} error=${JSON.stringify(job.error)}`);
|
|
1359
|
+
}
|
|
1360
|
+
const urls = urlsFromExportJob(job);
|
|
1361
|
+
if (urls.length === 0) {
|
|
1362
|
+
throw new Error("Canva export completed but returned no URLs.");
|
|
1363
|
+
}
|
|
1364
|
+
const assetType = exportType === "mp4" ? "video" : "image";
|
|
1365
|
+
const assetIds = await Promise.all(urls.map((u, i) => uploadExportedPageAsAsset(client2, company_id, u, i, design_id, assetType)));
|
|
1366
|
+
const draft = publish_at ? false : true;
|
|
1367
|
+
const postGroup = await client2.createPostGroup(company_id, {
|
|
1368
|
+
draft: draft ? 1 : 0,
|
|
1369
|
+
auto_publish: 0,
|
|
1370
|
+
...title ? { title } : {},
|
|
1371
|
+
...description ? { description } : {}
|
|
1372
|
+
});
|
|
1373
|
+
if (publish_at) {
|
|
1374
|
+
await client2.updatePostGroup(postGroup.id, { publish_at });
|
|
1375
|
+
}
|
|
1376
|
+
const posts = await Promise.all(social_networks.map(async (net) => {
|
|
1377
|
+
const post = await client2.createPost(postGroup.id, {
|
|
1378
|
+
social_network_type: net,
|
|
1379
|
+
assets_ids: assetIds,
|
|
1380
|
+
...description ? { description } : {},
|
|
1381
|
+
...title ? { title } : {}
|
|
1382
|
+
});
|
|
1383
|
+
return { network: net, post };
|
|
1384
|
+
}));
|
|
1385
|
+
return {
|
|
1386
|
+
content: [
|
|
1387
|
+
{
|
|
1388
|
+
type: "text",
|
|
1389
|
+
text: JSON.stringify({
|
|
1390
|
+
canva_export_job_id: start.job_id,
|
|
1391
|
+
asset_ids: assetIds,
|
|
1392
|
+
post_group: { id: postGroup.id, draft, publish_at: publish_at ?? null },
|
|
1393
|
+
posts
|
|
1394
|
+
}, null, 2)
|
|
1395
|
+
}
|
|
1396
|
+
]
|
|
1397
|
+
};
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// ../mcp-core/dist/tools/companies.js
|
|
1402
|
+
import { z as z6 } from "zod";
|
|
1403
|
+
function registerCompanyTools(server2, client2, _options) {
|
|
1404
|
+
server2.registerTool("list_companies", {
|
|
1405
|
+
title: "List Followr companies (workspaces)",
|
|
1406
|
+
description: "List the Followr workspaces (companies) accessible by the current API token. Use this first when the user asks about a specific workspace by name, to resolve its company_id.",
|
|
1407
|
+
inputSchema: {
|
|
1408
|
+
query: z6.string().optional().describe("Optional name filter (substring match)."),
|
|
1409
|
+
page_size: z6.number().int().positive().max(100).optional().describe("Items per page. Default 30.")
|
|
1410
|
+
}
|
|
1411
|
+
}, async ({ query, page_size }) => {
|
|
1412
|
+
const companies = await client2.listCompanies({ query, pageSize: page_size });
|
|
1413
|
+
return {
|
|
1414
|
+
content: [
|
|
1415
|
+
{
|
|
1416
|
+
type: "text",
|
|
1417
|
+
text: JSON.stringify(companies.map((c) => ({ id: c.id, name: c.name, type: c.type })), null, 2)
|
|
1418
|
+
}
|
|
1419
|
+
]
|
|
1420
|
+
};
|
|
1421
|
+
});
|
|
1422
|
+
server2.registerTool("get_company", {
|
|
1423
|
+
title: "Get a Followr company (workspace) by id",
|
|
1424
|
+
description: "Get full details of a Followr workspace, including AI preferences, audience targeting, brand voice fields, and webhook configuration. Use when the user asks about workspace settings.",
|
|
1425
|
+
inputSchema: {
|
|
1426
|
+
company_id: z6.number().int().positive().describe("The Followr company id.")
|
|
1427
|
+
}
|
|
1428
|
+
}, async ({ company_id }) => {
|
|
1429
|
+
const company = await client2.getCompany(company_id);
|
|
1430
|
+
const { webhook_secret, ai_keys, ...safeCompany } = company;
|
|
1431
|
+
const summary = {
|
|
1432
|
+
...safeCompany,
|
|
1433
|
+
webhook_secret_present: Boolean(webhook_secret),
|
|
1434
|
+
ai_keys_configured_providers: (ai_keys ?? []).map((k) => k.provider)
|
|
1435
|
+
};
|
|
1436
|
+
return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
|
|
1437
|
+
});
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// ../mcp-core/dist/tools/folders.js
|
|
1441
|
+
import { z as z7 } from "zod";
|
|
1442
|
+
var HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
1443
|
+
function registerFolderTools(server2, client2, _options) {
|
|
1444
|
+
server2.registerTool("list_folders", {
|
|
1445
|
+
title: "List folders in a workspace",
|
|
1446
|
+
description: "List folders (used to organize assets) in a Followr workspace. Optionally narrow to a specific parent folder (use parent_id_null=true to list only top-level folders).",
|
|
1447
|
+
inputSchema: {
|
|
1448
|
+
company_id: z7.number().int().positive(),
|
|
1449
|
+
parent_id: z7.number().int().positive().optional().describe("Restrict to children of this parent folder."),
|
|
1450
|
+
parent_id_null: z7.boolean().optional().describe("If true, only return top-level folders (parent_id is null). Ignored if parent_id is set."),
|
|
1451
|
+
page_size: z7.number().int().positive().max(100).optional()
|
|
1452
|
+
}
|
|
1453
|
+
}, async ({ company_id, parent_id, parent_id_null, page_size }) => {
|
|
1454
|
+
const parentArg = parent_id !== void 0 ? parent_id : parent_id_null ? null : void 0;
|
|
1455
|
+
const folders = await client2.listFolders(company_id, {
|
|
1456
|
+
...parentArg !== void 0 ? { parentId: parentArg } : {},
|
|
1457
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1458
|
+
});
|
|
1459
|
+
return {
|
|
1460
|
+
content: [
|
|
1461
|
+
{
|
|
1462
|
+
type: "text",
|
|
1463
|
+
text: JSON.stringify(folders.map((f) => ({
|
|
1464
|
+
id: f.id,
|
|
1465
|
+
name: f.name,
|
|
1466
|
+
color: f.color,
|
|
1467
|
+
parent_id: f.parent_id,
|
|
1468
|
+
created_at: f.created_at
|
|
1469
|
+
})), null, 2)
|
|
1470
|
+
}
|
|
1471
|
+
]
|
|
1472
|
+
};
|
|
1473
|
+
});
|
|
1474
|
+
server2.registerTool("get_folder", {
|
|
1475
|
+
title: "Get a single folder by id",
|
|
1476
|
+
description: "Fetch one folder's details (name, color, parent). Path is flat (/api/folders/{id}); nested variant returns 404.",
|
|
1477
|
+
inputSchema: {
|
|
1478
|
+
folder_id: z7.number().int().positive()
|
|
1479
|
+
}
|
|
1480
|
+
}, async ({ folder_id }) => {
|
|
1481
|
+
const folder = await client2.getFolder(folder_id);
|
|
1482
|
+
return { content: [{ type: "text", text: JSON.stringify(folder, null, 2) }] };
|
|
1483
|
+
});
|
|
1484
|
+
server2.registerTool("create_folder", {
|
|
1485
|
+
title: "Create a folder in a workspace",
|
|
1486
|
+
description: "Create a folder under a workspace, optionally nested inside a parent folder. Use to organize assets, generated images, or campaign material.",
|
|
1487
|
+
inputSchema: {
|
|
1488
|
+
company_id: z7.number().int().positive(),
|
|
1489
|
+
name: z7.string().min(1).describe("Folder name."),
|
|
1490
|
+
parent_id: z7.number().int().positive().optional().describe("Parent folder id. Omit for top-level."),
|
|
1491
|
+
color: z7.string().regex(HEX_COLOR).optional().describe("Hex color, e.g. #22c55e.")
|
|
1492
|
+
}
|
|
1493
|
+
}, async ({ company_id, name, parent_id, color }) => {
|
|
1494
|
+
const folder = await client2.createFolder(company_id, {
|
|
1495
|
+
name,
|
|
1496
|
+
...parent_id !== void 0 ? { parent_id } : {},
|
|
1497
|
+
...color ? { color } : {}
|
|
1498
|
+
});
|
|
1499
|
+
return { content: [{ type: "text", text: JSON.stringify(folder, null, 2) }] };
|
|
1500
|
+
});
|
|
1501
|
+
server2.registerTool("update_folder", {
|
|
1502
|
+
title: "Update a folder",
|
|
1503
|
+
description: "Patch a folder's name, color, or parent. Use to rename, recolor, or move into a different parent.",
|
|
1504
|
+
inputSchema: {
|
|
1505
|
+
folder_id: z7.number().int().positive(),
|
|
1506
|
+
name: z7.string().min(1).optional(),
|
|
1507
|
+
color: z7.string().regex(HEX_COLOR).optional(),
|
|
1508
|
+
parent_id: z7.number().int().positive().nullable().optional().describe("New parent folder id, or null to move to top-level.")
|
|
1509
|
+
}
|
|
1510
|
+
}, async ({ folder_id, ...patch }) => {
|
|
1511
|
+
const folder = await client2.updateFolder(folder_id, patch);
|
|
1512
|
+
return { content: [{ type: "text", text: JSON.stringify(folder, null, 2) }] };
|
|
1513
|
+
});
|
|
1514
|
+
server2.registerTool("delete_folder", {
|
|
1515
|
+
title: "Delete a folder (destructive)",
|
|
1516
|
+
description: "Permanently delete a folder. Cannot be undone. Behavior with nested folders or assets inside is not enforced server-side from the MCP's perspective; caller should clear contents first if needed.",
|
|
1517
|
+
inputSchema: {
|
|
1518
|
+
folder_id: z7.number().int().positive()
|
|
1519
|
+
}
|
|
1520
|
+
}, async ({ folder_id }) => {
|
|
1521
|
+
await client2.deleteFolder(folder_id);
|
|
1522
|
+
return { content: [{ type: "text", text: `Deleted folder ${folder_id}.` }] };
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
// ../mcp-core/dist/tools/post-groups.js
|
|
1527
|
+
import { z as z8 } from "zod";
|
|
1528
|
+
function registerPostGroupTools(server2, client2, options) {
|
|
1529
|
+
server2.registerTool("list_drafts", {
|
|
1530
|
+
title: "List pending drafts in a workspace",
|
|
1531
|
+
description: "Return PostGroups in draft state (not yet scheduled) for a workspace. Drafts are content waiting for approval or scheduling. Sorted newest first.",
|
|
1532
|
+
inputSchema: {
|
|
1533
|
+
company_id: z8.number().int().positive().describe("The Followr company id (workspace)."),
|
|
1534
|
+
page_size: z8.number().int().positive().max(100).optional().default(30)
|
|
1535
|
+
}
|
|
1536
|
+
}, async ({ company_id, page_size }) => {
|
|
1537
|
+
const groups = await client2.listCompanyPostGroups(company_id, {
|
|
1538
|
+
draft: true,
|
|
1539
|
+
sort: "-id",
|
|
1540
|
+
pageSize: page_size,
|
|
1541
|
+
include: "tags,posts,user"
|
|
1542
|
+
});
|
|
1543
|
+
return {
|
|
1544
|
+
content: [
|
|
1545
|
+
{
|
|
1546
|
+
type: "text",
|
|
1547
|
+
text: JSON.stringify(groups.map((g) => ({
|
|
1548
|
+
id: g.id,
|
|
1549
|
+
title: g.title,
|
|
1550
|
+
topic: g.topic,
|
|
1551
|
+
description: g.description?.slice(0, 200),
|
|
1552
|
+
publish_at: g.publish_at,
|
|
1553
|
+
draft: g.draft,
|
|
1554
|
+
auto_publish: g.auto_publish,
|
|
1555
|
+
created_at: g.created_at,
|
|
1556
|
+
networks: (g.posts ?? []).map((p) => p.social_network_type),
|
|
1557
|
+
tags: (g.tags ?? []).map((t) => t.name)
|
|
1558
|
+
})), null, 2)
|
|
1559
|
+
}
|
|
1560
|
+
]
|
|
1561
|
+
};
|
|
1562
|
+
});
|
|
1563
|
+
server2.registerTool("list_scheduled", {
|
|
1564
|
+
title: "List scheduled posts in a date range",
|
|
1565
|
+
description: "Return PostGroups scheduled (publish_at NOT NULL, draft=false) in a date range. Use for calendar queries like 'what do I have scheduled for next week'.",
|
|
1566
|
+
inputSchema: {
|
|
1567
|
+
company_id: z8.number().int().positive(),
|
|
1568
|
+
from_iso: z8.string().describe("ISO 8601 start of range. e.g. 2026-05-13T00:00:00Z"),
|
|
1569
|
+
to_iso: z8.string().describe("ISO 8601 end of range."),
|
|
1570
|
+
page_size: z8.number().int().positive().max(100).optional().default(50),
|
|
1571
|
+
social_networks: z8.array(z8.string()).optional().describe("Optional filter by network types (instagram, facebook, etc).")
|
|
1572
|
+
}
|
|
1573
|
+
}, async ({ company_id, from_iso, to_iso, page_size, social_networks }) => {
|
|
1574
|
+
const groups = await client2.listCompanyPostGroups(company_id, {
|
|
1575
|
+
draft: false,
|
|
1576
|
+
publishAtAfter: from_iso,
|
|
1577
|
+
publishAtBefore: to_iso,
|
|
1578
|
+
pageSize: page_size,
|
|
1579
|
+
sort: "publish_at",
|
|
1580
|
+
include: "tags,posts,user",
|
|
1581
|
+
...social_networks?.length ? { socialNetworkTypes: social_networks } : {}
|
|
1582
|
+
});
|
|
1583
|
+
return {
|
|
1584
|
+
content: [
|
|
1585
|
+
{
|
|
1586
|
+
type: "text",
|
|
1587
|
+
text: JSON.stringify(groups.map((g) => ({
|
|
1588
|
+
id: g.id,
|
|
1589
|
+
title: g.title,
|
|
1590
|
+
publish_at: g.publish_at,
|
|
1591
|
+
networks: (g.posts ?? []).map((p) => p.social_network_type),
|
|
1592
|
+
tags: (g.tags ?? []).map((t) => t.name)
|
|
1593
|
+
})), null, 2)
|
|
1594
|
+
}
|
|
1595
|
+
]
|
|
1596
|
+
};
|
|
1597
|
+
});
|
|
1598
|
+
server2.registerTool("get_post_group", {
|
|
1599
|
+
title: "Get full details of a PostGroup with hydrated assets",
|
|
1600
|
+
description: "Get a single PostGroup with all its posts, asset URLs (image and video thumbnails), tags, and the user who created it. Useful for showing a full preview.",
|
|
1601
|
+
inputSchema: {
|
|
1602
|
+
post_group_id: z8.number().int().positive()
|
|
1603
|
+
}
|
|
1604
|
+
}, async ({ post_group_id }) => {
|
|
1605
|
+
const group = await client2.getPostGroup(post_group_id);
|
|
1606
|
+
return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
|
|
1607
|
+
});
|
|
1608
|
+
server2.registerTool("create_post_group", {
|
|
1609
|
+
title: "Create a new PostGroup (draft or ready-to-schedule)",
|
|
1610
|
+
description: "Create a PostGroup in the specified workspace. Returns the new id. After creation, use create_post to add posts per social network, and update_post_group to set publish_at.",
|
|
1611
|
+
inputSchema: {
|
|
1612
|
+
company_id: z8.number().int().positive(),
|
|
1613
|
+
draft: z8.boolean().optional().default(true).describe("If true, post stays as draft. If false, it's ready to schedule."),
|
|
1614
|
+
auto_publish: z8.boolean().optional().default(false),
|
|
1615
|
+
title: z8.string().optional(),
|
|
1616
|
+
description: z8.string().optional()
|
|
1617
|
+
}
|
|
1618
|
+
}, async ({ company_id, draft, auto_publish, title, description }) => {
|
|
1619
|
+
const group = await client2.createPostGroup(company_id, {
|
|
1620
|
+
draft: draft ? 1 : 0,
|
|
1621
|
+
auto_publish: auto_publish ? 1 : 0,
|
|
1622
|
+
...title ? { title } : {},
|
|
1623
|
+
...description ? { description } : {}
|
|
1624
|
+
});
|
|
1625
|
+
return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
|
|
1626
|
+
});
|
|
1627
|
+
server2.registerTool("update_post_group", {
|
|
1628
|
+
title: "Update a PostGroup",
|
|
1629
|
+
description: "Patch fields of a PostGroup. Common use: schedule (set publish_at), change draft status, add or remove tags. tags_ids is REPLACE not append (caller must merge).",
|
|
1630
|
+
inputSchema: {
|
|
1631
|
+
post_group_id: z8.number().int().positive(),
|
|
1632
|
+
publish_at: z8.string().optional().describe("ISO 8601 datetime in UTC."),
|
|
1633
|
+
draft: z8.boolean().optional(),
|
|
1634
|
+
auto_publish: z8.boolean().optional(),
|
|
1635
|
+
title: z8.string().optional(),
|
|
1636
|
+
description: z8.string().optional(),
|
|
1637
|
+
tags_ids: z8.array(z8.number().int().positive()).optional().describe("Full list of tag ids (REPLACE semantics, not append).")
|
|
1638
|
+
}
|
|
1639
|
+
}, async (input) => {
|
|
1640
|
+
const { post_group_id, ...patch } = input;
|
|
1641
|
+
const group = await client2.updatePostGroup(post_group_id, patch);
|
|
1642
|
+
return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
|
|
1643
|
+
});
|
|
1644
|
+
server2.registerTool("delete_post_group", {
|
|
1645
|
+
title: "Delete a PostGroup (destructive)",
|
|
1646
|
+
description: "Permanently delete a PostGroup. Cannot be undone.",
|
|
1647
|
+
inputSchema: {
|
|
1648
|
+
post_group_id: z8.number().int().positive()
|
|
1649
|
+
}
|
|
1650
|
+
}, async ({ post_group_id }) => {
|
|
1651
|
+
await client2.deletePostGroup(post_group_id);
|
|
1652
|
+
return { content: [{ type: "text", text: `Deleted post_group ${post_group_id}.` }] };
|
|
1653
|
+
});
|
|
1654
|
+
server2.registerTool("publish_post_group_now", {
|
|
1655
|
+
title: "Force-publish a PostGroup immediately to a network",
|
|
1656
|
+
description: "Bypass scheduling and publish a PostGroup right now to the specified social network. Useful for crisis-response or trend-hijacking scenarios.",
|
|
1657
|
+
inputSchema: {
|
|
1658
|
+
post_group_id: z8.number().int().positive(),
|
|
1659
|
+
social_network_type: z8.string().describe("e.g. instagram, facebook, twitter, linkedin, tiktok, threads, bluesky")
|
|
1660
|
+
}
|
|
1661
|
+
}, async ({ post_group_id, social_network_type }) => {
|
|
1662
|
+
const result = await client2.publishPostGroup(post_group_id, social_network_type);
|
|
1663
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// ../mcp-core/dist/tools/prompts.js
|
|
1668
|
+
import { z as z9 } from "zod";
|
|
1669
|
+
var SOCIAL_NETWORK_TYPE = z9.enum([
|
|
1670
|
+
"facebook",
|
|
1671
|
+
"twitter",
|
|
1672
|
+
"instagram",
|
|
1673
|
+
"threads",
|
|
1674
|
+
"linkedin",
|
|
1675
|
+
"tiktok",
|
|
1676
|
+
"youtube",
|
|
1677
|
+
"pinterest",
|
|
1678
|
+
"bluesky",
|
|
1679
|
+
"medium"
|
|
1680
|
+
]);
|
|
1681
|
+
function registerPromptTools(server2, client2, _options) {
|
|
1682
|
+
server2.registerTool("list_prompts", {
|
|
1683
|
+
title: "List brand-voice prompts for a workspace",
|
|
1684
|
+
description: "List the brand-voice prompts of a workspace. These are the per-network prompt templates that Followr picks among when generating content (Post Generator, etc.). Optional filter by social network type and by `default=true`. Pass company_id=0 to see Followr's built-in defaults (where company_id is null in the API).",
|
|
1685
|
+
inputSchema: {
|
|
1686
|
+
company_id: z9.number().int().nonnegative().describe("Workspace id. Pass 0 to query Followr's built-in defaults (the API maps 0 \u2192 null)."),
|
|
1687
|
+
social_network_type: SOCIAL_NETWORK_TYPE.optional().describe("Restrict to one network."),
|
|
1688
|
+
only_default: z9.boolean().optional().describe("If true, only return prompts marked default."),
|
|
1689
|
+
page_size: z9.number().int().positive().max(100).optional()
|
|
1690
|
+
}
|
|
1691
|
+
}, async ({ company_id, social_network_type, only_default, page_size }) => {
|
|
1692
|
+
const prompts = await client2.listPrompts({
|
|
1693
|
+
companyId: company_id === 0 ? null : company_id,
|
|
1694
|
+
...social_network_type ? { socialNetworkType: social_network_type } : {},
|
|
1695
|
+
...only_default ? { onlyDefault: true } : {},
|
|
1696
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1697
|
+
});
|
|
1698
|
+
return {
|
|
1699
|
+
content: [
|
|
1700
|
+
{
|
|
1701
|
+
type: "text",
|
|
1702
|
+
text: JSON.stringify(prompts.map((p) => ({
|
|
1703
|
+
id: p.id,
|
|
1704
|
+
company_id: p.company_id,
|
|
1705
|
+
social_network_type: p.social_network_type,
|
|
1706
|
+
default: p.default,
|
|
1707
|
+
name: p.name,
|
|
1708
|
+
prompt: p.prompt,
|
|
1709
|
+
created_at: p.created_at
|
|
1710
|
+
})), null, 2)
|
|
1711
|
+
}
|
|
1712
|
+
]
|
|
1713
|
+
};
|
|
1714
|
+
});
|
|
1715
|
+
server2.registerTool("get_prompt", {
|
|
1716
|
+
title: "Get a single brand-voice prompt by id",
|
|
1717
|
+
description: "Fetch one brand-voice prompt by id. Useful to inspect its current text, default flag, and network assignment.",
|
|
1718
|
+
inputSchema: {
|
|
1719
|
+
prompt_id: z9.number().int().positive()
|
|
1720
|
+
}
|
|
1721
|
+
}, async ({ prompt_id }) => {
|
|
1722
|
+
const prompt = await client2.getPrompt(prompt_id);
|
|
1723
|
+
return { content: [{ type: "text", text: JSON.stringify(prompt, null, 2) }] };
|
|
1724
|
+
});
|
|
1725
|
+
server2.registerTool("create_prompt", {
|
|
1726
|
+
title: "Create a brand-voice prompt for a workspace",
|
|
1727
|
+
description: "Create a custom brand-voice prompt attached to a workspace and a specific social network. Followr will consider this prompt (alongside any built-in defaults and other workspace prompts) when generating content. Mark `default=true` to make it eligible for automatic selection. Multiple prompts with default=true per network are allowed; Followr picks one at generate time.",
|
|
1728
|
+
inputSchema: {
|
|
1729
|
+
company_id: z9.number().int().positive(),
|
|
1730
|
+
social_network_type: SOCIAL_NETWORK_TYPE,
|
|
1731
|
+
name: z9.string().min(1).max(80).describe("Short human-readable name shown in the Followr UI."),
|
|
1732
|
+
prompt: z9.string().min(1).describe("The actual prompt text used as system instructions when generating."),
|
|
1733
|
+
default: z9.boolean().optional().describe("If true, marks this prompt as eligible for automatic selection. Default false."),
|
|
1734
|
+
type: z9.string().optional().describe("Resource type. Default `text` (the only verified value). Reserved for future image/video prompt types.")
|
|
1735
|
+
}
|
|
1736
|
+
}, async ({ company_id, social_network_type, name, prompt, default: isDefault, type }) => {
|
|
1737
|
+
const created = await client2.createPrompt({
|
|
1738
|
+
company_id,
|
|
1739
|
+
social_network_type,
|
|
1740
|
+
name,
|
|
1741
|
+
prompt,
|
|
1742
|
+
...isDefault !== void 0 ? { default: isDefault } : {},
|
|
1743
|
+
...type ? { type } : {}
|
|
1744
|
+
});
|
|
1745
|
+
return { content: [{ type: "text", text: JSON.stringify(created, null, 2) }] };
|
|
1746
|
+
});
|
|
1747
|
+
server2.registerTool("update_prompt", {
|
|
1748
|
+
title: "Update a brand-voice prompt",
|
|
1749
|
+
description: "Patch an existing brand-voice prompt. Use to rename, edit the prompt text, change the network assignment, or toggle the `default` flag.",
|
|
1750
|
+
inputSchema: {
|
|
1751
|
+
prompt_id: z9.number().int().positive(),
|
|
1752
|
+
name: z9.string().min(1).max(80).optional(),
|
|
1753
|
+
prompt: z9.string().min(1).optional(),
|
|
1754
|
+
social_network_type: SOCIAL_NETWORK_TYPE.optional(),
|
|
1755
|
+
default: z9.boolean().optional()
|
|
1756
|
+
}
|
|
1757
|
+
}, async ({ prompt_id, ...patch }) => {
|
|
1758
|
+
const updated = await client2.updatePrompt(prompt_id, patch);
|
|
1759
|
+
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
1760
|
+
});
|
|
1761
|
+
server2.registerTool("delete_prompt", {
|
|
1762
|
+
title: "Delete a brand-voice prompt (destructive)",
|
|
1763
|
+
description: "Permanently delete a brand-voice prompt. Cannot be undone. Followr's built-in defaults (where company_id is null) cannot be deleted; only workspace-scoped prompts can.",
|
|
1764
|
+
inputSchema: {
|
|
1765
|
+
prompt_id: z9.number().int().positive()
|
|
1766
|
+
}
|
|
1767
|
+
}, async ({ prompt_id }) => {
|
|
1768
|
+
await client2.deletePrompt(prompt_id);
|
|
1769
|
+
return { content: [{ type: "text", text: `Deleted prompt ${prompt_id}.` }] };
|
|
1770
|
+
});
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// ../mcp-core/dist/tools/rule-groups.js
|
|
1774
|
+
import { z as z10 } from "zod";
|
|
1775
|
+
function registerRuleGroupTools(server2, client2, _options) {
|
|
1776
|
+
server2.registerTool("list_rule_groups", {
|
|
1777
|
+
title: "List Autopilot rule groups in a workspace",
|
|
1778
|
+
description: "List rule groups (Autopilot scheduling rules) in a workspace. A rule group bundles rules that auto-fill empty calendar slots from a pool of tagged PostGroups. Includes the underlying rules array by default.",
|
|
1779
|
+
inputSchema: {
|
|
1780
|
+
company_id: z10.number().int().positive(),
|
|
1781
|
+
include: z10.string().optional().describe("Override include chain. Default: rules.")
|
|
1782
|
+
}
|
|
1783
|
+
}, async ({ company_id, include }) => {
|
|
1784
|
+
const groups = await client2.listRuleGroups(company_id, {
|
|
1785
|
+
include: include ?? "rules"
|
|
1786
|
+
});
|
|
1787
|
+
return {
|
|
1788
|
+
content: [
|
|
1789
|
+
{
|
|
1790
|
+
type: "text",
|
|
1791
|
+
text: JSON.stringify(groups.map((g) => ({
|
|
1792
|
+
id: g.id,
|
|
1793
|
+
name: g.name,
|
|
1794
|
+
description: g.description,
|
|
1795
|
+
active: g.active,
|
|
1796
|
+
random_minutes: g.random_minutes,
|
|
1797
|
+
rules_count: g.rules?.length ?? 0,
|
|
1798
|
+
rules: g.rules
|
|
1799
|
+
})), null, 2)
|
|
1800
|
+
}
|
|
1801
|
+
]
|
|
1802
|
+
};
|
|
1803
|
+
});
|
|
1804
|
+
server2.registerTool("get_rule_group", {
|
|
1805
|
+
title: "Get a single rule group by id",
|
|
1806
|
+
description: "Fetch one Autopilot rule group's details. Path is flat (/api/ruleGroups/{id}).",
|
|
1807
|
+
inputSchema: {
|
|
1808
|
+
rule_group_id: z10.number().int().positive()
|
|
1809
|
+
}
|
|
1810
|
+
}, async ({ rule_group_id }) => {
|
|
1811
|
+
const group = await client2.getRuleGroup(rule_group_id);
|
|
1812
|
+
return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
|
|
1813
|
+
});
|
|
1814
|
+
server2.registerTool("create_rule_group", {
|
|
1815
|
+
title: "Create an Autopilot rule group",
|
|
1816
|
+
description: "Create a new Autopilot rule group in a workspace. After creation, individual rules (days_of_week, time_slots, social_network_types, tag filters) must be added separately. random_minutes adds jitter to scheduled times to avoid bot-like patterns.",
|
|
1817
|
+
inputSchema: {
|
|
1818
|
+
company_id: z10.number().int().positive(),
|
|
1819
|
+
name: z10.string().min(1),
|
|
1820
|
+
description: z10.string().optional(),
|
|
1821
|
+
is_active: z10.boolean().optional().describe("If true, the rule group is active immediately. Default false."),
|
|
1822
|
+
random_minutes: z10.number().int().min(0).max(120).optional().describe("Random jitter in minutes applied to scheduled times. Default 0.")
|
|
1823
|
+
}
|
|
1824
|
+
}, async ({ company_id, name, description, is_active, random_minutes }) => {
|
|
1825
|
+
const group = await client2.createRuleGroup({
|
|
1826
|
+
company_id,
|
|
1827
|
+
name,
|
|
1828
|
+
...description !== void 0 ? { description } : {},
|
|
1829
|
+
...is_active !== void 0 ? { is_active } : {},
|
|
1830
|
+
...random_minutes !== void 0 ? { random_minutes } : {}
|
|
1831
|
+
});
|
|
1832
|
+
return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
|
|
1833
|
+
});
|
|
1834
|
+
server2.registerTool("update_rule_group", {
|
|
1835
|
+
title: "Update an Autopilot rule group",
|
|
1836
|
+
description: "Patch a rule group's name, description, active flag, or random jitter.",
|
|
1837
|
+
inputSchema: {
|
|
1838
|
+
rule_group_id: z10.number().int().positive(),
|
|
1839
|
+
name: z10.string().min(1).optional(),
|
|
1840
|
+
description: z10.string().optional(),
|
|
1841
|
+
active: z10.boolean().optional(),
|
|
1842
|
+
random_minutes: z10.number().int().min(0).max(120).optional()
|
|
1843
|
+
}
|
|
1844
|
+
}, async ({ rule_group_id, ...patch }) => {
|
|
1845
|
+
const group = await client2.updateRuleGroup(rule_group_id, patch);
|
|
1846
|
+
return { content: [{ type: "text", text: JSON.stringify(group, null, 2) }] };
|
|
1847
|
+
});
|
|
1848
|
+
server2.registerTool("delete_rule_group", {
|
|
1849
|
+
title: "Delete an Autopilot rule group (destructive)",
|
|
1850
|
+
description: "Permanently delete an Autopilot rule group. Already-scheduled posts that were filled by this group will remain on the calendar (deletion only affects future autopilot fills). Cannot be undone.",
|
|
1851
|
+
inputSchema: {
|
|
1852
|
+
rule_group_id: z10.number().int().positive()
|
|
1853
|
+
}
|
|
1854
|
+
}, async ({ rule_group_id }) => {
|
|
1855
|
+
await client2.deleteRuleGroup(rule_group_id);
|
|
1856
|
+
return { content: [{ type: "text", text: `Deleted rule_group ${rule_group_id}.` }] };
|
|
1857
|
+
});
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// ../mcp-core/dist/tools/social-hub.js
|
|
1861
|
+
import { z as z11 } from "zod";
|
|
1862
|
+
function registerSocialHubTools(server2, client2, _options) {
|
|
1863
|
+
server2.registerTool("list_conversations", {
|
|
1864
|
+
title: "List inbox conversations (DMs) for a workspace",
|
|
1865
|
+
description: "List Social Hub conversations across all connected accounts in a workspace, newest activity first. Each entry includes the external user (DM sender), last message preview, and unread count. Use this to triage the inbox or auto-reply.",
|
|
1866
|
+
inputSchema: {
|
|
1867
|
+
company_id: z11.number().int().positive(),
|
|
1868
|
+
social_network_id: z11.number().int().positive().optional().describe("Optional: limit to a specific connected account."),
|
|
1869
|
+
only_unread: z11.boolean().optional().describe("If true, only return conversations with unread messages."),
|
|
1870
|
+
page_size: z11.number().int().positive().max(100).optional()
|
|
1871
|
+
}
|
|
1872
|
+
}, async ({ company_id, social_network_id, only_unread, page_size }) => {
|
|
1873
|
+
const conversations = await client2.listConversations(company_id, {
|
|
1874
|
+
...social_network_id !== void 0 ? { socialNetworkId: social_network_id } : {},
|
|
1875
|
+
...only_unread ? { hasUnreadMessages: true } : {},
|
|
1876
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1877
|
+
});
|
|
1878
|
+
return {
|
|
1879
|
+
content: [
|
|
1880
|
+
{
|
|
1881
|
+
type: "text",
|
|
1882
|
+
text: JSON.stringify(conversations.map((c) => ({
|
|
1883
|
+
id: c.id,
|
|
1884
|
+
social_network_id: c.social_network_id,
|
|
1885
|
+
external_user: c.externalUser ? {
|
|
1886
|
+
id: c.externalUser.id,
|
|
1887
|
+
name: c.externalUser.name,
|
|
1888
|
+
username: c.externalUser.username,
|
|
1889
|
+
type: c.externalUser.type
|
|
1890
|
+
} : null,
|
|
1891
|
+
last_message_preview: c.lastMessage?.message?.slice(0, 200) ?? null,
|
|
1892
|
+
last_message_at: c.lastMessage?.created_at ?? null,
|
|
1893
|
+
unread_count: c.unreadMessages_count ?? 0,
|
|
1894
|
+
updated_at: c.updated_at
|
|
1895
|
+
})), null, 2)
|
|
1896
|
+
}
|
|
1897
|
+
]
|
|
1898
|
+
};
|
|
1899
|
+
});
|
|
1900
|
+
server2.registerTool("get_conversation_messages", {
|
|
1901
|
+
title: "Get messages in a conversation",
|
|
1902
|
+
description: "Return the messages within a single conversation, newest first. Use this after list_conversations to read full thread content before deciding on a reply.",
|
|
1903
|
+
inputSchema: {
|
|
1904
|
+
conversation_id: z11.number().int().positive(),
|
|
1905
|
+
page_size: z11.number().int().positive().max(100).optional()
|
|
1906
|
+
}
|
|
1907
|
+
}, async ({ conversation_id, page_size }) => {
|
|
1908
|
+
const messages = await client2.listMessages(conversation_id, {
|
|
1909
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1910
|
+
});
|
|
1911
|
+
return {
|
|
1912
|
+
content: [
|
|
1913
|
+
{
|
|
1914
|
+
type: "text",
|
|
1915
|
+
text: JSON.stringify(messages, null, 2)
|
|
1916
|
+
}
|
|
1917
|
+
]
|
|
1918
|
+
};
|
|
1919
|
+
});
|
|
1920
|
+
server2.registerTool("mark_conversation_read", {
|
|
1921
|
+
title: "Mark a conversation as read",
|
|
1922
|
+
description: "Mark a Social Hub conversation as read (clears the unread badge). Use this after processing messages from get_conversation_messages.",
|
|
1923
|
+
inputSchema: {
|
|
1924
|
+
conversation_id: z11.number().int().positive()
|
|
1925
|
+
}
|
|
1926
|
+
}, async ({ conversation_id }) => {
|
|
1927
|
+
const updated = await client2.markConversationRead(conversation_id);
|
|
1928
|
+
return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
|
|
1929
|
+
});
|
|
1930
|
+
server2.registerTool("list_platform_messages", {
|
|
1931
|
+
title: "List messages via the platform-native endpoint (Facebook or Instagram only)",
|
|
1932
|
+
description: "Read messages using Followr's platform-specific proxy to the Meta Graph API. Only supported for Facebook and Instagram conversations (other networks like LinkedIn, TikTok, X, Threads, Bluesky return 404 from this proxy and should use get_conversation_messages instead). Returns the raw Graph API shape with created_time, from, id, message, to fields.",
|
|
1933
|
+
inputSchema: {
|
|
1934
|
+
platform: z11.enum(["facebook", "instagram"]).describe("Only facebook and instagram are supported."),
|
|
1935
|
+
conversation_id: z11.number().int().positive()
|
|
1936
|
+
}
|
|
1937
|
+
}, async ({ platform, conversation_id }) => {
|
|
1938
|
+
const messages = await client2.listPlatformMessages(platform, conversation_id);
|
|
1939
|
+
return { content: [{ type: "text", text: JSON.stringify(messages, null, 2) }] };
|
|
1940
|
+
});
|
|
1941
|
+
server2.registerTool("list_contacts", {
|
|
1942
|
+
title: "List external users (contacts) in a workspace",
|
|
1943
|
+
description: "List external users associated with a workspace. External users are DM senders, followers, or comment authors collected across all connected accounts. Use this to see who has interacted with the brand recently. Internally calls /api/externalUsers (which is the same resource the Followr UI's Contacts page reads from).",
|
|
1944
|
+
inputSchema: {
|
|
1945
|
+
company_id: z11.number().int().positive(),
|
|
1946
|
+
type: z11.string().optional().describe("Optional filter by external user type (e.g. follower, message_sender, comment_author)."),
|
|
1947
|
+
page_size: z11.number().int().positive().max(100).optional()
|
|
1948
|
+
}
|
|
1949
|
+
}, async ({ company_id, type, page_size }) => {
|
|
1950
|
+
const contacts = await client2.listExternalUsers(company_id, {
|
|
1951
|
+
...type ? { type } : {},
|
|
1952
|
+
...page_size ? { pageSize: page_size } : {}
|
|
1953
|
+
});
|
|
1954
|
+
return {
|
|
1955
|
+
content: [
|
|
1956
|
+
{
|
|
1957
|
+
type: "text",
|
|
1958
|
+
text: JSON.stringify(contacts.map((c) => ({
|
|
1959
|
+
id: c.id,
|
|
1960
|
+
name: c.name,
|
|
1961
|
+
username: c.username,
|
|
1962
|
+
type: c.type,
|
|
1963
|
+
external_id: c.external_id,
|
|
1964
|
+
last_interaction_at: c.last_interaction_at,
|
|
1965
|
+
description: c.description
|
|
1966
|
+
})), null, 2)
|
|
1967
|
+
}
|
|
1968
|
+
]
|
|
1969
|
+
};
|
|
1970
|
+
});
|
|
1971
|
+
server2.registerTool("list_comments", {
|
|
1972
|
+
title: "List comments on published posts in a workspace",
|
|
1973
|
+
description: "Return comments left on the workspace's published posts. Use this for community-moderation workflows: surface new comments, draft replies, escalate negative sentiment. Optionally narrow to a single post via post_id.",
|
|
1974
|
+
inputSchema: {
|
|
1975
|
+
company_id: z11.number().int().positive(),
|
|
1976
|
+
post_id: z11.number().int().positive().optional().describe("Optional: limit to a single post."),
|
|
1977
|
+
page_size: z11.number().int().positive().max(100).optional(),
|
|
1978
|
+
include: z11.string().optional().describe("Optional include chain (e.g. 'externalUser,post').")
|
|
1979
|
+
}
|
|
1980
|
+
}, async ({ company_id, post_id, page_size, include }) => {
|
|
1981
|
+
const comments = await client2.listComments(company_id, {
|
|
1982
|
+
...post_id !== void 0 ? { postId: post_id } : {},
|
|
1983
|
+
...page_size ? { pageSize: page_size } : {},
|
|
1984
|
+
...include ? { include } : {}
|
|
1985
|
+
});
|
|
1986
|
+
return { content: [{ type: "text", text: JSON.stringify(comments, null, 2) }] };
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// ../mcp-core/dist/tools/subscription.js
|
|
1991
|
+
function registerSubscriptionTools(server2, client2, _options) {
|
|
1992
|
+
server2.registerTool("get_credits_balance", {
|
|
1993
|
+
title: "Get the current credit and quota balance for the API token",
|
|
1994
|
+
description: "Return the subscription balance for the current API token: AI credits remaining, words allowed/spent, images allowed/spent, bytes (storage) allowed/spent, plan features (whitelabel, plus_chat, getlead), and renewal timestamp. The balance is scoped to the token's owner (per-user), not per-workspace. Use this before kicking off expensive operations like generate_avatar_video (775+ credits) or create_avatar_full_flow (25+ credits).",
|
|
1995
|
+
inputSchema: {}
|
|
1996
|
+
}, async () => {
|
|
1997
|
+
const balance = await client2.getSubscriptionBalance();
|
|
1998
|
+
return { content: [{ type: "text", text: JSON.stringify(balance, null, 2) }] };
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
// ../mcp-core/dist/tools/tags.js
|
|
2003
|
+
import { z as z12 } from "zod";
|
|
2004
|
+
function registerTagTools(server2, client2, _options) {
|
|
2005
|
+
server2.registerTool("list_tags", {
|
|
2006
|
+
title: "List tags in a workspace",
|
|
2007
|
+
description: "List all tags for a Followr workspace. Tags are scoped to a company and used for categorizing PostGroups (and as a workaround for approval status via the 'Approved' / 'Rejected' convention).",
|
|
2008
|
+
inputSchema: {
|
|
2009
|
+
company_id: z12.number().int().positive()
|
|
2010
|
+
}
|
|
2011
|
+
}, async ({ company_id }) => {
|
|
2012
|
+
const tags = await client2.listTags(company_id);
|
|
2013
|
+
return {
|
|
2014
|
+
content: [
|
|
2015
|
+
{
|
|
2016
|
+
type: "text",
|
|
2017
|
+
text: JSON.stringify(tags.map((t) => ({ id: t.id, name: t.name, color: t.color, active: t.active })), null, 2)
|
|
2018
|
+
}
|
|
2019
|
+
]
|
|
2020
|
+
};
|
|
2021
|
+
});
|
|
2022
|
+
server2.registerTool("create_tag", {
|
|
2023
|
+
title: "Create a tag in a workspace",
|
|
2024
|
+
description: "Create a new tag in the specified workspace. Color is optional hex (e.g. #22c55e). Tags are idempotent by convention: caller should list existing first to avoid duplicates.",
|
|
2025
|
+
inputSchema: {
|
|
2026
|
+
company_id: z12.number().int().positive(),
|
|
2027
|
+
name: z12.string().min(1),
|
|
2028
|
+
color: z12.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Hex color, e.g. #22c55e")
|
|
2029
|
+
}
|
|
2030
|
+
}, async ({ company_id, name, color }) => {
|
|
2031
|
+
const tag = await client2.createTag({ company_id, name, ...color ? { color } : {} });
|
|
2032
|
+
return { content: [{ type: "text", text: JSON.stringify(tag, null, 2) }] };
|
|
2033
|
+
});
|
|
2034
|
+
server2.registerTool("update_tag", {
|
|
2035
|
+
title: "Update a tag (rename, change color or active state)",
|
|
2036
|
+
description: "Patch a tag's name, color, or active flag.",
|
|
2037
|
+
inputSchema: {
|
|
2038
|
+
tag_id: z12.number().int().positive(),
|
|
2039
|
+
name: z12.string().optional(),
|
|
2040
|
+
color: z12.string().regex(/^#[0-9a-fA-F]{6}$/).optional(),
|
|
2041
|
+
active: z12.boolean().optional()
|
|
2042
|
+
}
|
|
2043
|
+
}, async ({ tag_id, ...patch }) => {
|
|
2044
|
+
const tag = await client2.updateTag(tag_id, patch);
|
|
2045
|
+
return { content: [{ type: "text", text: JSON.stringify(tag, null, 2) }] };
|
|
2046
|
+
});
|
|
2047
|
+
server2.registerTool("delete_tag", {
|
|
2048
|
+
title: "Delete a tag (destructive)",
|
|
2049
|
+
description: "Permanently delete a tag. PostGroups that referenced this tag will keep their tags_ids list with a now-broken reference. Cannot be undone.",
|
|
2050
|
+
inputSchema: {
|
|
2051
|
+
tag_id: z12.number().int().positive()
|
|
2052
|
+
}
|
|
2053
|
+
}, async ({ tag_id }) => {
|
|
2054
|
+
await client2.deleteTag(tag_id);
|
|
2055
|
+
return { content: [{ type: "text", text: `Deleted tag ${tag_id}.` }] };
|
|
2056
|
+
});
|
|
2057
|
+
server2.registerTool("find_or_create_tag", {
|
|
2058
|
+
title: "Find a tag by name or create it if it doesn't exist",
|
|
2059
|
+
description: "Idempotent helper. Looks up tags in the workspace by case-insensitive name match. If found, returns its id. If not, creates a new tag with the given name and color.",
|
|
2060
|
+
inputSchema: {
|
|
2061
|
+
company_id: z12.number().int().positive(),
|
|
2062
|
+
name: z12.string().min(1),
|
|
2063
|
+
color: z12.string().regex(/^#[0-9a-fA-F]{6}$/).optional()
|
|
2064
|
+
}
|
|
2065
|
+
}, async ({ company_id, name, color }) => {
|
|
2066
|
+
const tags = await client2.listTags(company_id);
|
|
2067
|
+
const existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
2068
|
+
if (existing) {
|
|
2069
|
+
return {
|
|
2070
|
+
content: [{ type: "text", text: JSON.stringify({ found: true, tag: existing }, null, 2) }]
|
|
2071
|
+
};
|
|
2072
|
+
}
|
|
2073
|
+
const created = await client2.createTag({ company_id, name, ...color ? { color } : {} });
|
|
2074
|
+
return {
|
|
2075
|
+
content: [{ type: "text", text: JSON.stringify({ found: false, tag: created }, null, 2) }]
|
|
2076
|
+
};
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
// ../mcp-core/dist/tools/users.js
|
|
2081
|
+
import { z as z13 } from "zod";
|
|
2082
|
+
function registerUserTools(server2, client2, _options) {
|
|
2083
|
+
server2.registerTool("get_current_user", {
|
|
2084
|
+
title: "Get the user that owns the current API token",
|
|
2085
|
+
description: "Return the Followr user identified by the API token in use: id, name, email, timezone, language, credit balance. Use this at the start of a conversation to anchor 'who am I' and to discover the user's timezone for scheduling decisions.",
|
|
2086
|
+
inputSchema: {}
|
|
2087
|
+
}, async () => {
|
|
2088
|
+
const me = await client2.getMe();
|
|
2089
|
+
const safe = {
|
|
2090
|
+
id: me.id,
|
|
2091
|
+
name: me.name,
|
|
2092
|
+
email: me.email,
|
|
2093
|
+
timezone: me.timezone,
|
|
2094
|
+
language: me.language,
|
|
2095
|
+
credits: me.credits,
|
|
2096
|
+
has_password: me.has_password,
|
|
2097
|
+
created_at: me.created_at
|
|
2098
|
+
};
|
|
2099
|
+
return { content: [{ type: "text", text: JSON.stringify(safe, null, 2) }] };
|
|
2100
|
+
});
|
|
2101
|
+
server2.registerTool("list_team_users", {
|
|
2102
|
+
title: "List users with access to a workspace",
|
|
2103
|
+
description: "List Followr users that have access to a specific workspace (team members). Uses the relation filter `companies.id`. Use to map ownership, find collaborators, or check who created a draft.",
|
|
2104
|
+
inputSchema: {
|
|
2105
|
+
company_id: z13.number().int().positive(),
|
|
2106
|
+
page_size: z13.number().int().positive().max(100).optional()
|
|
2107
|
+
}
|
|
2108
|
+
}, async ({ company_id, page_size }) => {
|
|
2109
|
+
const users = await client2.listUsersInCompany(company_id, {
|
|
2110
|
+
...page_size ? { pageSize: page_size } : {}
|
|
2111
|
+
});
|
|
2112
|
+
return {
|
|
2113
|
+
content: [
|
|
2114
|
+
{
|
|
2115
|
+
type: "text",
|
|
2116
|
+
text: JSON.stringify(users.map((u) => ({
|
|
2117
|
+
id: u.id,
|
|
2118
|
+
name: u.name,
|
|
2119
|
+
email: u.email,
|
|
2120
|
+
timezone: u.timezone,
|
|
2121
|
+
language: u.language
|
|
2122
|
+
})), null, 2)
|
|
2123
|
+
}
|
|
2124
|
+
]
|
|
2125
|
+
};
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// ../mcp-core/dist/tools/voices.js
|
|
2130
|
+
import { z as z14 } from "zod";
|
|
2131
|
+
function sanitizeVoice(voice) {
|
|
2132
|
+
const v = voice;
|
|
2133
|
+
if (v.company) {
|
|
2134
|
+
const { ai_keys: _ak, webhook_secret: _ws, ...safeCompany } = v.company;
|
|
2135
|
+
v.company = safeCompany;
|
|
2136
|
+
}
|
|
2137
|
+
return v;
|
|
2138
|
+
}
|
|
2139
|
+
function registerVoiceTools(server2, client2, _options) {
|
|
2140
|
+
server2.registerTool("list_voices", {
|
|
2141
|
+
title: "List voices in a workspace",
|
|
2142
|
+
description: "List voice profiles already created in a Followr workspace. Each voice is linked to a TTS provider (typically ElevenLabs) and can be assigned to an avatar or used directly for audio generation. Use this before generate_audio or create_avatar_full_flow to discover available voices.",
|
|
2143
|
+
inputSchema: {
|
|
2144
|
+
company_id: z14.number().int().positive(),
|
|
2145
|
+
page_size: z14.number().int().positive().max(100).optional()
|
|
2146
|
+
}
|
|
2147
|
+
}, async ({ company_id, page_size }) => {
|
|
2148
|
+
const voices = await client2.listVoices(company_id, {
|
|
2149
|
+
...page_size ? { pageSize: page_size } : {}
|
|
2150
|
+
});
|
|
2151
|
+
return {
|
|
2152
|
+
content: [
|
|
2153
|
+
{
|
|
2154
|
+
type: "text",
|
|
2155
|
+
text: JSON.stringify(voices.map((v) => ({
|
|
2156
|
+
id: v.id,
|
|
2157
|
+
name: v.name,
|
|
2158
|
+
language_code: v.language_code,
|
|
2159
|
+
platform: v.platform,
|
|
2160
|
+
platform_external_id: v.platform_external_id,
|
|
2161
|
+
accent: v.accent,
|
|
2162
|
+
description: v.description
|
|
2163
|
+
})), null, 2)
|
|
2164
|
+
}
|
|
2165
|
+
]
|
|
2166
|
+
};
|
|
2167
|
+
});
|
|
2168
|
+
server2.registerTool("get_voice", {
|
|
2169
|
+
title: "Get a single voice with its audio sample",
|
|
2170
|
+
description: "Fetch one voice by id, with the audio sample hydrated. Use this to confirm a freshly created voice has its sample uploaded, or to retrieve the audio preview URL.",
|
|
2171
|
+
inputSchema: {
|
|
2172
|
+
voice_id: z14.number().int().positive()
|
|
2173
|
+
}
|
|
2174
|
+
}, async ({ voice_id }) => {
|
|
2175
|
+
const voice = await client2.getVoice(voice_id);
|
|
2176
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeVoice(voice), null, 2) }] };
|
|
2177
|
+
});
|
|
2178
|
+
server2.registerTool("list_elevenlabs_voices", {
|
|
2179
|
+
title: "Browse the ElevenLabs voice catalog with optional filters",
|
|
2180
|
+
description: "Returns a page of voices from the ElevenLabs catalog with rich metadata (language, gender, age, accent, use_case, preview_url, social handles). Supports client-side filtering by language, gender, category, and free-text query against name and description. Use this to pick a voice_id before calling create_voice_from_elevenlabs.",
|
|
2181
|
+
inputSchema: {
|
|
2182
|
+
page: z14.number().int().positive().optional().describe("API page number. 30 voices per page. Default 1."),
|
|
2183
|
+
language: z14.string().optional().describe("Filter by ISO 639-1 code (en, es, pt, fr, etc.)."),
|
|
2184
|
+
gender: z14.enum(["male", "female", "non-binary"]).optional(),
|
|
2185
|
+
category: z14.string().optional().describe("e.g. professional, casual."),
|
|
2186
|
+
query: z14.string().optional().describe("Substring match against name or description (case-insensitive)."),
|
|
2187
|
+
featured_only: z14.boolean().optional().describe("If true, only return voices marked featured.")
|
|
2188
|
+
}
|
|
2189
|
+
}, async ({ page, language, gender, category, query, featured_only }) => {
|
|
2190
|
+
const all = await client2.listElevenlabsVoices({ ...page ? { page } : {} });
|
|
2191
|
+
const q = query?.toLowerCase();
|
|
2192
|
+
const filtered = all.filter((v) => {
|
|
2193
|
+
if (language && v.language !== language)
|
|
2194
|
+
return false;
|
|
2195
|
+
if (gender && v.gender !== gender)
|
|
2196
|
+
return false;
|
|
2197
|
+
if (category && v.category !== category)
|
|
2198
|
+
return false;
|
|
2199
|
+
if (featured_only && !v.featured)
|
|
2200
|
+
return false;
|
|
2201
|
+
if (q) {
|
|
2202
|
+
const hay = `${v.name ?? ""} ${v.description ?? ""}`.toLowerCase();
|
|
2203
|
+
if (!hay.includes(q))
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
return true;
|
|
2207
|
+
});
|
|
2208
|
+
const slim = filtered.map((v) => ({
|
|
2209
|
+
voice_id: v.voice_id,
|
|
2210
|
+
name: v.name,
|
|
2211
|
+
language: v.language,
|
|
2212
|
+
locale: v.locale,
|
|
2213
|
+
gender: v.gender,
|
|
2214
|
+
age: v.age,
|
|
2215
|
+
accent: v.accent,
|
|
2216
|
+
category: v.category,
|
|
2217
|
+
description: v.description,
|
|
2218
|
+
use_case: v.use_case,
|
|
2219
|
+
preview_url: v.preview_url,
|
|
2220
|
+
featured: v.featured
|
|
2221
|
+
}));
|
|
2222
|
+
return { content: [{ type: "text", text: JSON.stringify(slim, null, 2) }] };
|
|
2223
|
+
});
|
|
2224
|
+
server2.registerTool("create_voice_from_elevenlabs", {
|
|
2225
|
+
title: "Create a Followr voice linked to an ElevenLabs voice",
|
|
2226
|
+
description: "Create a Voice resource in the workspace that wraps an ElevenLabs voice_id. Required: name, language_code (ISO 639-1), elevenlabs_voice_id (from list_elevenlabs_voices). The voice is usable immediately for generate_audio and create_avatar_full_flow even though the optional audio sample upload is not part of this tool (voice.audio will be null until manually uploaded). Cannot be undone via the MCP (voice resource persists).",
|
|
2227
|
+
inputSchema: {
|
|
2228
|
+
company_id: z14.number().int().positive(),
|
|
2229
|
+
name: z14.string().min(1).max(50).describe("Human-readable voice name."),
|
|
2230
|
+
language_code: z14.string().min(2).max(5).describe("ISO 639-1 code, e.g. en, es, pt, fr."),
|
|
2231
|
+
elevenlabs_voice_id: z14.string().min(1).describe("ElevenLabs voice_id from list_elevenlabs_voices."),
|
|
2232
|
+
accent: z14.string().optional(),
|
|
2233
|
+
description: z14.string().optional()
|
|
2234
|
+
}
|
|
2235
|
+
}, async ({ company_id, name, language_code, elevenlabs_voice_id, accent, description }) => {
|
|
2236
|
+
const voice = await client2.createVoice(company_id, {
|
|
2237
|
+
name,
|
|
2238
|
+
language_code,
|
|
2239
|
+
platform: "elevenlabs",
|
|
2240
|
+
platform_external_id: elevenlabs_voice_id,
|
|
2241
|
+
...accent !== void 0 ? { accent } : {},
|
|
2242
|
+
...description !== void 0 ? { description } : {}
|
|
2243
|
+
});
|
|
2244
|
+
return { content: [{ type: "text", text: JSON.stringify(sanitizeVoice(voice), null, 2) }] };
|
|
2245
|
+
});
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// ../mcp-core/dist/tools/workspace-settings.js
|
|
2249
|
+
import { z as z15 } from "zod";
|
|
2250
|
+
function registerWorkspaceSettingsTools(server2, client2, _options) {
|
|
2251
|
+
server2.registerTool("update_webhook_url", {
|
|
2252
|
+
title: "Update the workspace webhook URL and secret",
|
|
2253
|
+
description: "Set or rotate the workspace's outbound webhook (used when a Post is published or fails). Both fields can be cleared with empty string. The secret never round-trips: stored and signed against, never echoed back; the response only confirms the URL.",
|
|
2254
|
+
inputSchema: {
|
|
2255
|
+
company_id: z15.number().int().positive(),
|
|
2256
|
+
webhook_posts_url: z15.string().describe("Destination URL for outbound events. Empty string clears."),
|
|
2257
|
+
webhook_secret: z15.string().optional().describe("Shared secret used to sign payloads. Optional. Empty string clears.")
|
|
2258
|
+
}
|
|
2259
|
+
}, async ({ company_id, webhook_posts_url, webhook_secret }) => {
|
|
2260
|
+
const updated = await client2.updateCompany(company_id, {
|
|
2261
|
+
webhook_posts_url,
|
|
2262
|
+
...webhook_secret !== void 0 ? { webhook_secret } : {}
|
|
2263
|
+
});
|
|
2264
|
+
return {
|
|
2265
|
+
content: [
|
|
2266
|
+
{
|
|
2267
|
+
type: "text",
|
|
2268
|
+
text: JSON.stringify({
|
|
2269
|
+
id: updated.id,
|
|
2270
|
+
webhook_posts_url: updated.webhook_posts_url,
|
|
2271
|
+
webhook_secret_present: Boolean(updated.webhook_secret)
|
|
2272
|
+
}, null, 2)
|
|
2273
|
+
}
|
|
2274
|
+
]
|
|
2275
|
+
};
|
|
2276
|
+
});
|
|
2277
|
+
server2.registerTool("set_menu_visibility", {
|
|
2278
|
+
title: "Set the workspace's left-menu visibility flags",
|
|
2279
|
+
description: "Update which menu sections are visible in the Followr SPA for a workspace. The field `menu_visibility` is a map of section name to boolean. REPLACE semantics: the map passed becomes the new value. Useful for whitelabel installs that want to hide irrelevant sections.",
|
|
2280
|
+
inputSchema: {
|
|
2281
|
+
company_id: z15.number().int().positive(),
|
|
2282
|
+
menu_visibility: z15.record(z15.boolean()).describe("Full map of section_name -> visible. REPLACE, not merge.")
|
|
2283
|
+
}
|
|
2284
|
+
}, async ({ company_id, menu_visibility }) => {
|
|
2285
|
+
const updated = await client2.updateCompany(company_id, {
|
|
2286
|
+
menu_visibility
|
|
2287
|
+
});
|
|
2288
|
+
return {
|
|
2289
|
+
content: [
|
|
2290
|
+
{
|
|
2291
|
+
type: "text",
|
|
2292
|
+
text: JSON.stringify({ id: updated.id, menu_visibility: updated.menu_visibility }, null, 2)
|
|
2293
|
+
}
|
|
2294
|
+
]
|
|
2295
|
+
};
|
|
2296
|
+
});
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// ../mcp-core/dist/resources/index.js
|
|
2300
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2301
|
+
function sanitizeCompany(c) {
|
|
2302
|
+
const { ai_keys, webhook_secret, ...safe } = c;
|
|
2303
|
+
return {
|
|
2304
|
+
...safe,
|
|
2305
|
+
webhook_secret_present: Boolean(webhook_secret),
|
|
2306
|
+
ai_keys_configured_providers: (ai_keys ?? []).map((k) => k.provider)
|
|
2307
|
+
};
|
|
2308
|
+
}
|
|
2309
|
+
function defaultCalendarRange() {
|
|
2310
|
+
const now = Date.now();
|
|
2311
|
+
const from = new Date(now - 7 * 24 * 60 * 60 * 1e3).toISOString();
|
|
2312
|
+
const to = new Date(now + 23 * 24 * 60 * 60 * 1e3).toISOString();
|
|
2313
|
+
return { from, to };
|
|
2314
|
+
}
|
|
2315
|
+
function registerFollowrResources(server2, client2, _options) {
|
|
2316
|
+
server2.registerResource("companies", "followr://companies", {
|
|
2317
|
+
title: "Workspaces catalog",
|
|
2318
|
+
description: "Catalog of Followr workspaces (companies) accessible to the current API token. Returns id, name, type, language, and timezone metadata. Read this once at session start to anchor which workspaces exist.",
|
|
2319
|
+
mimeType: "application/json"
|
|
2320
|
+
}, async (uri) => {
|
|
2321
|
+
const companies = await client2.listCompanies();
|
|
2322
|
+
const slim = companies.map((c) => ({
|
|
2323
|
+
id: c.id,
|
|
2324
|
+
name: c.name,
|
|
2325
|
+
type: c.type,
|
|
2326
|
+
language: c.language,
|
|
2327
|
+
country_iso_code: c.country_iso_code,
|
|
2328
|
+
created_at: c.created_at
|
|
2329
|
+
}));
|
|
2330
|
+
return {
|
|
2331
|
+
contents: [
|
|
2332
|
+
{
|
|
2333
|
+
uri: uri.href,
|
|
2334
|
+
mimeType: "application/json",
|
|
2335
|
+
text: JSON.stringify(slim, null, 2)
|
|
2336
|
+
}
|
|
2337
|
+
]
|
|
2338
|
+
};
|
|
2339
|
+
});
|
|
2340
|
+
server2.registerResource("post-group", new ResourceTemplate("followr://post-group/{id}", { list: void 0 }), {
|
|
2341
|
+
title: "Hydrated PostGroup",
|
|
2342
|
+
description: "Read a single PostGroup with all its posts, asset URLs (image and video thumbnails), tags, and creator. Use as a stable reference document instead of repeatedly calling get_post_group.",
|
|
2343
|
+
mimeType: "application/json"
|
|
2344
|
+
}, async (uri, variables) => {
|
|
2345
|
+
const rawId = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
|
|
2346
|
+
const postGroupId = Number(rawId);
|
|
2347
|
+
if (!Number.isInteger(postGroupId) || postGroupId <= 0) {
|
|
2348
|
+
throw new Error(`Invalid post-group id in URI ${uri.href}: ${rawId}`);
|
|
2349
|
+
}
|
|
2350
|
+
const group = await client2.getPostGroup(postGroupId);
|
|
2351
|
+
return {
|
|
2352
|
+
contents: [
|
|
2353
|
+
{
|
|
2354
|
+
uri: uri.href,
|
|
2355
|
+
mimeType: "application/json",
|
|
2356
|
+
text: JSON.stringify(group, null, 2)
|
|
2357
|
+
}
|
|
2358
|
+
]
|
|
2359
|
+
};
|
|
2360
|
+
});
|
|
2361
|
+
server2.registerResource("calendar", new ResourceTemplate("followr://company/{id}/calendar", {
|
|
2362
|
+
list: async () => {
|
|
2363
|
+
const companies = await client2.listCompanies();
|
|
2364
|
+
return {
|
|
2365
|
+
resources: companies.map((c) => ({
|
|
2366
|
+
uri: `followr://company/${c.id}/calendar`,
|
|
2367
|
+
name: `Calendar for ${c.name}`,
|
|
2368
|
+
mimeType: "application/json"
|
|
2369
|
+
}))
|
|
2370
|
+
};
|
|
2371
|
+
}
|
|
2372
|
+
}), {
|
|
2373
|
+
title: "Workspace calendar (next 30 days)",
|
|
2374
|
+
description: "Read the scheduled posts of a workspace in a default window from 7 days ago to 23 days from now. For a custom date range, use the list_scheduled tool with explicit from_iso / to_iso instead.",
|
|
2375
|
+
mimeType: "application/json"
|
|
2376
|
+
}, async (uri, variables) => {
|
|
2377
|
+
const rawId = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
|
|
2378
|
+
const companyId = Number(rawId);
|
|
2379
|
+
if (!Number.isInteger(companyId) || companyId <= 0) {
|
|
2380
|
+
throw new Error(`Invalid company id in URI ${uri.href}: ${rawId}`);
|
|
2381
|
+
}
|
|
2382
|
+
const { from, to } = defaultCalendarRange();
|
|
2383
|
+
const groups = await client2.listCompanyPostGroups(companyId, {
|
|
2384
|
+
draft: false,
|
|
2385
|
+
publishAtAfter: from,
|
|
2386
|
+
publishAtBefore: to,
|
|
2387
|
+
sort: "publish_at",
|
|
2388
|
+
pageSize: 100,
|
|
2389
|
+
include: "tags,posts"
|
|
2390
|
+
});
|
|
2391
|
+
const items = groups.map((g) => ({
|
|
2392
|
+
id: g.id,
|
|
2393
|
+
title: g.title,
|
|
2394
|
+
publish_at: g.publish_at,
|
|
2395
|
+
networks: (g.posts ?? []).map((p) => p.social_network_type),
|
|
2396
|
+
tags: (g.tags ?? []).map((t) => t.name)
|
|
2397
|
+
}));
|
|
2398
|
+
return {
|
|
2399
|
+
contents: [
|
|
2400
|
+
{
|
|
2401
|
+
uri: uri.href,
|
|
2402
|
+
mimeType: "application/json",
|
|
2403
|
+
text: JSON.stringify({ company_id: companyId, window: { from, to }, items }, null, 2)
|
|
2404
|
+
}
|
|
2405
|
+
]
|
|
2406
|
+
};
|
|
2407
|
+
});
|
|
2408
|
+
server2.registerResource("brand", new ResourceTemplate("followr://company/{id}/brand", {
|
|
2409
|
+
list: async () => {
|
|
2410
|
+
const companies = await client2.listCompanies();
|
|
2411
|
+
return {
|
|
2412
|
+
resources: companies.map((c) => ({
|
|
2413
|
+
uri: `followr://company/${c.id}/brand`,
|
|
2414
|
+
name: `Brand voice for ${c.name}`,
|
|
2415
|
+
mimeType: "application/json"
|
|
2416
|
+
}))
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2419
|
+
}), {
|
|
2420
|
+
title: "Workspace brand voice and audience settings",
|
|
2421
|
+
description: "Read the brand-related fields of a workspace: per-network brand voice prompts, AI preferences (driver/model defaults), audience ages/genders/types, tones, palettes, and language type. Use to anchor copy and image generation to the brand's voice.",
|
|
2422
|
+
mimeType: "application/json"
|
|
2423
|
+
}, async (uri, variables) => {
|
|
2424
|
+
const rawId = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
|
|
2425
|
+
const companyId = Number(rawId);
|
|
2426
|
+
if (!Number.isInteger(companyId) || companyId <= 0) {
|
|
2427
|
+
throw new Error(`Invalid company id in URI ${uri.href}: ${rawId}`);
|
|
2428
|
+
}
|
|
2429
|
+
const company = await client2.getCompany(companyId);
|
|
2430
|
+
const safe = sanitizeCompany(company);
|
|
2431
|
+
const brand = {
|
|
2432
|
+
id: safe.id,
|
|
2433
|
+
name: safe.name,
|
|
2434
|
+
language: safe.language,
|
|
2435
|
+
language_iso_code: safe.language_iso_code,
|
|
2436
|
+
description: safe.description,
|
|
2437
|
+
website: safe.website,
|
|
2438
|
+
ai_preferences: safe.ai_preferences,
|
|
2439
|
+
ai_image_styles: safe.ai_image_styles,
|
|
2440
|
+
audience_ages: safe.audience_ages,
|
|
2441
|
+
audience_genders: safe.audience_genders,
|
|
2442
|
+
audience_types: safe.audience_types,
|
|
2443
|
+
interests: safe.interests,
|
|
2444
|
+
language_types: safe.language_types,
|
|
2445
|
+
palettes: safe.palettes,
|
|
2446
|
+
tones: safe.tones,
|
|
2447
|
+
syntaxes: safe.syntaxes,
|
|
2448
|
+
fonts: safe.fonts,
|
|
2449
|
+
emotions: safe.emotions,
|
|
2450
|
+
characters: safe.characters,
|
|
2451
|
+
social_network_prompts: safe.social_network_prompts
|
|
2452
|
+
};
|
|
2453
|
+
return {
|
|
2454
|
+
contents: [
|
|
2455
|
+
{
|
|
2456
|
+
uri: uri.href,
|
|
2457
|
+
mimeType: "application/json",
|
|
2458
|
+
text: JSON.stringify(brand, null, 2)
|
|
2459
|
+
}
|
|
2460
|
+
]
|
|
2461
|
+
};
|
|
2462
|
+
});
|
|
2463
|
+
server2.registerResource("avatars", new ResourceTemplate("followr://company/{id}/avatars", {
|
|
2464
|
+
list: async () => {
|
|
2465
|
+
const companies = await client2.listCompanies();
|
|
2466
|
+
return {
|
|
2467
|
+
resources: companies.map((c) => ({
|
|
2468
|
+
uri: `followr://company/${c.id}/avatars`,
|
|
2469
|
+
name: `Avatars for ${c.name}`,
|
|
2470
|
+
mimeType: "application/json"
|
|
2471
|
+
}))
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
}), {
|
|
2475
|
+
title: "Avatars catalog for a workspace",
|
|
2476
|
+
description: "Read the avatar catalog of a workspace, hydrated with image, voice (with audio sample), and scenes. Use to pick an avatar before generate_avatar_video.",
|
|
2477
|
+
mimeType: "application/json"
|
|
2478
|
+
}, async (uri, variables) => {
|
|
2479
|
+
const rawId = Array.isArray(variables["id"]) ? variables["id"][0] : variables["id"];
|
|
2480
|
+
const companyId = Number(rawId);
|
|
2481
|
+
if (!Number.isInteger(companyId) || companyId <= 0) {
|
|
2482
|
+
throw new Error(`Invalid company id in URI ${uri.href}: ${rawId}`);
|
|
2483
|
+
}
|
|
2484
|
+
const avatars = await client2.listAvatars(companyId);
|
|
2485
|
+
const slim = avatars.map((a) => ({
|
|
2486
|
+
id: a.id,
|
|
2487
|
+
name: a.name,
|
|
2488
|
+
description: a.description,
|
|
2489
|
+
default: a.default,
|
|
2490
|
+
voice_id: a.voice_id,
|
|
2491
|
+
image_url: a.image?.url,
|
|
2492
|
+
voice_name: a.voice?.name,
|
|
2493
|
+
voice_platform: a.voice?.platform,
|
|
2494
|
+
scenes_count: a.scenes?.length ?? 0
|
|
2495
|
+
}));
|
|
2496
|
+
return {
|
|
2497
|
+
contents: [
|
|
2498
|
+
{
|
|
2499
|
+
uri: uri.href,
|
|
2500
|
+
mimeType: "application/json",
|
|
2501
|
+
text: JSON.stringify(slim, null, 2)
|
|
2502
|
+
}
|
|
2503
|
+
]
|
|
2504
|
+
};
|
|
2505
|
+
});
|
|
2506
|
+
server2.registerResource("voices-elevenlabs", "followr://voices/elevenlabs", {
|
|
2507
|
+
title: "ElevenLabs voice catalog (first page)",
|
|
2508
|
+
description: "Read the first page (30 voices) of the ElevenLabs catalog with rich metadata (language, gender, age, accent, use_case, preview_url). For server-side filtering by language/gender/category, use the list_elevenlabs_voices tool instead.",
|
|
2509
|
+
mimeType: "application/json"
|
|
2510
|
+
}, async (uri) => {
|
|
2511
|
+
const voices = await client2.listElevenlabsVoices();
|
|
2512
|
+
const slim = voices.map((v) => ({
|
|
2513
|
+
voice_id: v.voice_id,
|
|
2514
|
+
name: v.name,
|
|
2515
|
+
language: v.language,
|
|
2516
|
+
gender: v.gender,
|
|
2517
|
+
age: v.age,
|
|
2518
|
+
accent: v.accent,
|
|
2519
|
+
category: v.category,
|
|
2520
|
+
description: v.description,
|
|
2521
|
+
use_case: v.use_case,
|
|
2522
|
+
preview_url: v.preview_url,
|
|
2523
|
+
featured: v.featured
|
|
2524
|
+
}));
|
|
2525
|
+
return {
|
|
2526
|
+
contents: [
|
|
2527
|
+
{
|
|
2528
|
+
uri: uri.href,
|
|
2529
|
+
mimeType: "application/json",
|
|
2530
|
+
text: JSON.stringify(slim, null, 2)
|
|
2531
|
+
}
|
|
2532
|
+
]
|
|
2533
|
+
};
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// ../mcp-core/dist/prompts/index.js
|
|
2538
|
+
import { z as z16 } from "zod";
|
|
2539
|
+
function registerFollowrPrompts(server2, _client, _options) {
|
|
2540
|
+
server2.registerPrompt("followr.weekly-brief", {
|
|
2541
|
+
title: "Generate a week of scheduled posts from a brief",
|
|
2542
|
+
description: "Take a free-form weekly brief and produce a full week of scheduled posts in the workspace. Anchors to the workspace's brand voice, picks suitable networks, drafts copy + images, and schedules across the week.",
|
|
2543
|
+
argsSchema: {
|
|
2544
|
+
company_id: z16.string().describe("Followr company id."),
|
|
2545
|
+
brief: z16.string().describe("Free-form brief for the week. Topics, hooks, must-mentions, banned terms, target audience notes."),
|
|
2546
|
+
networks: z16.string().optional().describe("Comma-separated network types (instagram, facebook, etc). Defaults to all connected accounts."),
|
|
2547
|
+
posts_per_day: z16.string().optional().describe("Integer; default 1."),
|
|
2548
|
+
starting_iso_date: z16.string().optional().describe("ISO 8601 date for day 1 of the week. Defaults to tomorrow at 10:00 in the workspace timezone.")
|
|
2549
|
+
}
|
|
2550
|
+
}, ({ company_id, brief, networks, posts_per_day, starting_iso_date }) => ({
|
|
2551
|
+
messages: [
|
|
2552
|
+
{
|
|
2553
|
+
role: "user",
|
|
2554
|
+
content: {
|
|
2555
|
+
type: "text",
|
|
2556
|
+
text: `You are operating inside the Followr MCP. Plan and schedule a full week of social posts for company_id=${company_id} based on this brief:
|
|
2557
|
+
|
|
2558
|
+
<brief>
|
|
2559
|
+
${brief}
|
|
2560
|
+
</brief>
|
|
2561
|
+
|
|
2562
|
+
Constraints:
|
|
2563
|
+
- Networks: ${networks ?? "use every connected account in the workspace"}
|
|
2564
|
+
- Posts per day: ${posts_per_day ?? "1"}
|
|
2565
|
+
- Starting from: ${starting_iso_date ?? "tomorrow at 10:00 in the workspace timezone"}
|
|
2566
|
+
|
|
2567
|
+
Procedure:
|
|
2568
|
+
1. Read the brand voice from the followr://company/${company_id}/brand resource.
|
|
2569
|
+
2. Read the workspace calendar from followr://company/${company_id}/calendar to avoid collisions.
|
|
2570
|
+
3. Draft N post ideas honoring brand voice and brief.
|
|
2571
|
+
4. For each idea: optionally generate an image with generate_image, then create_post_group + create_post for the chosen networks, then update_post_group with publish_at.
|
|
2572
|
+
5. Return a summary: { post_group_id, publish_at, networks, title } for each scheduled post.`
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
]
|
|
2576
|
+
}));
|
|
2577
|
+
server2.registerPrompt("followr.campaign-launch", {
|
|
2578
|
+
title: "Launch a multi-network campaign end-to-end",
|
|
2579
|
+
description: "Spin up a full campaign in one shot: hashtag/tag taxonomy, brand-aligned hero asset, teaser + launch + follow-up posts across selected networks, scheduled around the launch date.",
|
|
2580
|
+
argsSchema: {
|
|
2581
|
+
company_id: z16.string().describe("Followr company id."),
|
|
2582
|
+
campaign_name: z16.string(),
|
|
2583
|
+
launch_iso_date: z16.string().describe("ISO 8601 launch datetime in UTC."),
|
|
2584
|
+
networks: z16.string().describe("Comma-separated network types."),
|
|
2585
|
+
product_or_offer: z16.string().describe("What the campaign is selling or announcing."),
|
|
2586
|
+
primary_cta: z16.string().describe("The single CTA (e.g. 'Sign up at acme.com/launch')."),
|
|
2587
|
+
teaser_days_before: z16.string().optional().describe("How many days before launch to start teasers. Default 3."),
|
|
2588
|
+
followup_days_after: z16.string().optional().describe("How many days after launch to keep follow-up posts. Default 7.")
|
|
2589
|
+
}
|
|
2590
|
+
}, ({ company_id, campaign_name, launch_iso_date, networks, product_or_offer, primary_cta, teaser_days_before, followup_days_after }) => ({
|
|
2591
|
+
messages: [
|
|
2592
|
+
{
|
|
2593
|
+
role: "user",
|
|
2594
|
+
content: {
|
|
2595
|
+
type: "text",
|
|
2596
|
+
text: `Launch the "${campaign_name}" campaign for company_id=${company_id}.
|
|
2597
|
+
|
|
2598
|
+
Inputs:
|
|
2599
|
+
- Launch date: ${launch_iso_date}
|
|
2600
|
+
- Networks: ${networks}
|
|
2601
|
+
- Product / offer: ${product_or_offer}
|
|
2602
|
+
- Primary CTA: ${primary_cta}
|
|
2603
|
+
- Teaser window: ${teaser_days_before ?? "3"} days before launch
|
|
2604
|
+
- Follow-up window: ${followup_days_after ?? "7"} days after launch
|
|
2605
|
+
|
|
2606
|
+
Procedure:
|
|
2607
|
+
1. Read brand voice via followr://company/${company_id}/brand.
|
|
2608
|
+
2. Use find_or_create_tag to ensure a tag named "${campaign_name}" exists; remember its id.
|
|
2609
|
+
3. Generate one hero visual with generate_image (image-to-image with brand reference if available).
|
|
2610
|
+
4. Upload the hero via upload_image_from_url (or attach the AI-generated URL).
|
|
2611
|
+
5. Draft and schedule: (a) a teaser series in the teaser window, (b) a launch announcement on the launch date, (c) follow-up posts and social proof across the follow-up window.
|
|
2612
|
+
6. Tag every PostGroup with the campaign tag id.
|
|
2613
|
+
7. Return a manifest: array of { post_group_id, role: 'teaser' | 'launch' | 'followup', publish_at, networks }.`
|
|
2614
|
+
}
|
|
2615
|
+
}
|
|
2616
|
+
]
|
|
2617
|
+
}));
|
|
2618
|
+
server2.registerPrompt("followr.video-series", {
|
|
2619
|
+
title: "Generate an avatar video series on a single topic",
|
|
2620
|
+
description: "Produce N short avatar videos on a topic. One script per episode, one lipsync render per episode, all scheduled across N consecutive days.",
|
|
2621
|
+
argsSchema: {
|
|
2622
|
+
company_id: z16.string(),
|
|
2623
|
+
avatar_id: z16.string().describe("Avatar to use (from list_avatars or create_avatar_full_flow)."),
|
|
2624
|
+
topic: z16.string().describe("The topic / theme of the series."),
|
|
2625
|
+
episode_count: z16.string().describe("Number of episodes (1-30 reasonable)."),
|
|
2626
|
+
networks: z16.string().describe("Comma-separated network types. Vertical 9:16 will be used for Reels / Shorts."),
|
|
2627
|
+
starting_iso_date: z16.string().describe("ISO 8601 datetime for episode 1."),
|
|
2628
|
+
cadence: z16.string().optional().describe("daily | every-other-day | weekly. Default daily.")
|
|
2629
|
+
}
|
|
2630
|
+
}, ({ company_id, avatar_id, topic, episode_count, networks, starting_iso_date, cadence }) => ({
|
|
2631
|
+
messages: [
|
|
2632
|
+
{
|
|
2633
|
+
role: "user",
|
|
2634
|
+
content: {
|
|
2635
|
+
type: "text",
|
|
2636
|
+
text: `Produce an avatar video series for company_id=${company_id} using avatar_id=${avatar_id}.
|
|
2637
|
+
|
|
2638
|
+
Inputs:
|
|
2639
|
+
- Topic: ${topic}
|
|
2640
|
+
- Episodes: ${episode_count}
|
|
2641
|
+
- Networks: ${networks}
|
|
2642
|
+
- Starting: ${starting_iso_date}
|
|
2643
|
+
- Cadence: ${cadence ?? "daily"}
|
|
2644
|
+
|
|
2645
|
+
Procedure:
|
|
2646
|
+
1. Read brand voice via followr://company/${company_id}/brand.
|
|
2647
|
+
2. With generate_text, brainstorm N distinct episode angles for the topic, each with a 100-150 character on-camera script.
|
|
2648
|
+
3. For each episode, call generate_avatar_video with avatar_id and the script. WARNING: each render costs ~775 credits. Confirm credit balance via get_credits_balance BEFORE proceeding if uncertain.
|
|
2649
|
+
4. After each video completes, create_post_group + create_post(network) with the video asset attached, then update_post_group with publish_at.
|
|
2650
|
+
5. Return a manifest of { episode_n, post_group_id, publish_at, ai_result_id, networks }.`
|
|
2651
|
+
}
|
|
2652
|
+
}
|
|
2653
|
+
]
|
|
2654
|
+
}));
|
|
2655
|
+
server2.registerPrompt("followr.crisis-response", {
|
|
2656
|
+
title: "Draft three crisis-response variants for review",
|
|
2657
|
+
description: "Quickly produce three differently-toned crisis-response post drafts (apology, clarification, deflection) staged as drafts (not auto-publish) so a human can approve one.",
|
|
2658
|
+
argsSchema: {
|
|
2659
|
+
company_id: z16.string(),
|
|
2660
|
+
situation: z16.string().describe("What happened, key facts, sensitivity notes."),
|
|
2661
|
+
networks: z16.string().describe("Comma-separated network types."),
|
|
2662
|
+
urgency_window_hours: z16.string().optional().describe("How many hours until the post should ideally go live. Default 4.")
|
|
2663
|
+
}
|
|
2664
|
+
}, ({ company_id, situation, networks, urgency_window_hours }) => ({
|
|
2665
|
+
messages: [
|
|
2666
|
+
{
|
|
2667
|
+
role: "user",
|
|
2668
|
+
content: {
|
|
2669
|
+
type: "text",
|
|
2670
|
+
text: `Handle a crisis-response need for company_id=${company_id}.
|
|
2671
|
+
|
|
2672
|
+
Situation:
|
|
2673
|
+
${situation}
|
|
2674
|
+
|
|
2675
|
+
Constraints:
|
|
2676
|
+
- Networks: ${networks}
|
|
2677
|
+
- Urgency window: ${urgency_window_hours ?? "4"} hours
|
|
2678
|
+
|
|
2679
|
+
Procedure:
|
|
2680
|
+
1. Read brand voice via followr://company/${company_id}/brand. Honor it but lean conservative.
|
|
2681
|
+
2. With generate_text (or your own composition), draft THREE distinct response variants:
|
|
2682
|
+
- Variant A: full apology + concrete remediation.
|
|
2683
|
+
- Variant B: clarification (assumes the situation is a misunderstanding).
|
|
2684
|
+
- Variant C: deflection / "no comment beyond this short statement".
|
|
2685
|
+
3. For EACH variant: create_post_group with draft=true (do NOT publish), title prefixed "CRISIS DRAFT - ". Create one post per network with the variant text.
|
|
2686
|
+
4. Tag every PostGroup with a "crisis-${Date.now()}" tag via find_or_create_tag.
|
|
2687
|
+
5. Return three post_group_ids with their variant labels. Do NOT call publish_post_group_now. A human will pick one and publish.`
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
]
|
|
2691
|
+
}));
|
|
2692
|
+
server2.registerPrompt("followr.repurpose-from-url", {
|
|
2693
|
+
title: "Repurpose a single URL into multi-network posts",
|
|
2694
|
+
description: "Given a URL (blog post, news article, video), produce network-tailored versions across the requested networks, with the right format (carousel, single image, video, blog post) per network.",
|
|
2695
|
+
argsSchema: {
|
|
2696
|
+
company_id: z16.string(),
|
|
2697
|
+
source_url: z16.string().describe("The URL to repurpose (article, blog post, video)."),
|
|
2698
|
+
networks: z16.string().describe("Comma-separated networks."),
|
|
2699
|
+
publish_at: z16.string().optional().describe("ISO 8601 datetime. If omitted, leave as draft."),
|
|
2700
|
+
include_visual: z16.string().optional().describe("If 'true', generate a fresh image per network format. Default 'true'.")
|
|
2701
|
+
}
|
|
2702
|
+
}, ({ company_id, source_url, networks, publish_at, include_visual }) => ({
|
|
2703
|
+
messages: [
|
|
2704
|
+
{
|
|
2705
|
+
role: "user",
|
|
2706
|
+
content: {
|
|
2707
|
+
type: "text",
|
|
2708
|
+
text: `Repurpose ${source_url} into multi-network posts for company_id=${company_id}.
|
|
2709
|
+
|
|
2710
|
+
Constraints:
|
|
2711
|
+
- Networks: ${networks}
|
|
2712
|
+
- Publish at: ${publish_at ?? "leave as draft"}
|
|
2713
|
+
- Generate visuals: ${include_visual ?? "true"}
|
|
2714
|
+
|
|
2715
|
+
Procedure:
|
|
2716
|
+
1. Read brand voice via followr://company/${company_id}/brand.
|
|
2717
|
+
2. Fetch the URL (use your native web tools) and extract: title, key takeaways (3-7 bullets), tone, suggested hashtags.
|
|
2718
|
+
3. For EACH network, tailor format and length:
|
|
2719
|
+
- twitter/X: 1-2 punchy posts, 280 chars max, link to source.
|
|
2720
|
+
- linkedin: 1 long-form post with 3-5 takeaways and a soft CTA.
|
|
2721
|
+
- instagram: caption + ${include_visual === "false" ? "no image" : "1-3 generated images forming a carousel"}.
|
|
2722
|
+
- facebook: similar to linkedin, slightly more casual.
|
|
2723
|
+
- tiktok / youtube: if requested, generate a short script and (optionally) an avatar video via generate_avatar_video.
|
|
2724
|
+
- medium (blog post): full repost in workspace voice with credit to source.
|
|
2725
|
+
4. If include_visual is true, call generate_image once per format that needs visuals (use image_url=primary image of the source as reference for consistency).
|
|
2726
|
+
5. Create one PostGroup tied together by a shared tag (find_or_create_tag). One post per network. Schedule with publish_at if provided.
|
|
2727
|
+
6. Return a manifest: { source_url, post_group_id, posts: [{ network, draft_preview, asset_count }] }.`
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
]
|
|
2731
|
+
}));
|
|
2732
|
+
}
|
|
2733
|
+
|
|
2734
|
+
// ../mcp-core/dist/index.js
|
|
2735
|
+
function registerFollowrTools(server2, client2, options = {}) {
|
|
2736
|
+
registerCompanyTools(server2, client2, options);
|
|
2737
|
+
registerPostGroupTools(server2, client2, options);
|
|
2738
|
+
registerTagTools(server2, client2, options);
|
|
2739
|
+
registerAiResultsTools(server2, client2, options);
|
|
2740
|
+
registerAvatarTools(server2, client2, options);
|
|
2741
|
+
registerVoiceTools(server2, client2, options);
|
|
2742
|
+
registerSocialHubTools(server2, client2, options);
|
|
2743
|
+
registerFolderTools(server2, client2, options);
|
|
2744
|
+
registerRuleGroupTools(server2, client2, options);
|
|
2745
|
+
registerSubscriptionTools(server2, client2, options);
|
|
2746
|
+
registerUserTools(server2, client2, options);
|
|
2747
|
+
registerWorkspaceSettingsTools(server2, client2, options);
|
|
2748
|
+
registerPromptTools(server2, client2, options);
|
|
2749
|
+
registerAnalyticsTools(server2, client2, options);
|
|
2750
|
+
registerAssetTools(server2, client2, options);
|
|
2751
|
+
registerCanvaTools(server2, client2, options);
|
|
2752
|
+
registerFollowrResources(server2, client2, options);
|
|
2753
|
+
registerFollowrPrompts(server2, client2, options);
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
// bin/followr-mcp.ts
|
|
2757
|
+
var token = process.env["FOLLOWR_API_TOKEN"];
|
|
2758
|
+
if (!token) {
|
|
2759
|
+
console.error(
|
|
2760
|
+
[
|
|
2761
|
+
"Error: FOLLOWR_API_TOKEN env var is not set.",
|
|
2762
|
+
"",
|
|
2763
|
+
"Generate a token in Followr:",
|
|
2764
|
+
" 1. Go to https://app.followr.ai",
|
|
2765
|
+
" 2. Settings > Company Settings > API Keys",
|
|
2766
|
+
" 3. Click 'Generate' and copy the value",
|
|
2767
|
+
"",
|
|
2768
|
+
"Then add it to your Claude Desktop config (or equivalent):",
|
|
2769
|
+
' "followr": {',
|
|
2770
|
+
' "command": "npx",',
|
|
2771
|
+
' "args": ["-y", "@followr/mcp"],',
|
|
2772
|
+
' "env": { "FOLLOWR_API_TOKEN": "your_token_here" }',
|
|
2773
|
+
" }"
|
|
2774
|
+
].join("\n")
|
|
2775
|
+
);
|
|
2776
|
+
process.exit(1);
|
|
2777
|
+
}
|
|
2778
|
+
var baseUrl = process.env["FOLLOWR_API_BASE_URL"];
|
|
2779
|
+
var client = new FollowrClient({
|
|
2780
|
+
token,
|
|
2781
|
+
...baseUrl ? { baseUrl } : {}
|
|
2782
|
+
});
|
|
2783
|
+
var server = new McpServer({
|
|
2784
|
+
name: "followr",
|
|
2785
|
+
version: "0.1.0"
|
|
2786
|
+
});
|
|
2787
|
+
registerFollowrTools(server, client);
|
|
2788
|
+
var transport = new StdioServerTransport();
|
|
2789
|
+
await server.connect(transport);
|
|
2790
|
+
//# sourceMappingURL=followr-mcp.js.map
|