@buzzposter/mcp 0.1.5 → 0.1.7

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/dist/tools.js ADDED
@@ -0,0 +1,4251 @@
1
+ // src/client.ts
2
+ var BuzzPosterClient = class {
3
+ baseUrl;
4
+ apiKey;
5
+ constructor(config) {
6
+ this.baseUrl = config.baseUrl.replace(/\/$/, "");
7
+ this.apiKey = config.apiKey;
8
+ }
9
+ async request(method, path, body, query) {
10
+ const url = new URL(`${this.baseUrl}${path}`);
11
+ if (query) {
12
+ for (const [k, v] of Object.entries(query)) {
13
+ if (v !== void 0 && v !== "") url.searchParams.set(k, v);
14
+ }
15
+ }
16
+ const headers = {
17
+ Authorization: `Bearer ${this.apiKey}`
18
+ };
19
+ if (body) {
20
+ headers["Content-Type"] = "application/json";
21
+ }
22
+ const res = await fetch(url, {
23
+ method,
24
+ headers,
25
+ body: body ? JSON.stringify(body) : void 0
26
+ });
27
+ if (!res.ok) {
28
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
29
+ const message = typeof errorBody === "object" && errorBody !== null && "message" in errorBody ? String(errorBody.message) : `API error (${res.status})`;
30
+ throw new Error(`BuzzPoster API error (${res.status}): ${message}`);
31
+ }
32
+ const text = await res.text();
33
+ return text ? JSON.parse(text) : void 0;
34
+ }
35
+ // Account
36
+ async getAccount() {
37
+ return this.request("GET", "/api/v1/account");
38
+ }
39
+ // Social accounts
40
+ async listAccounts() {
41
+ return this.request("GET", "/api/v1/accounts");
42
+ }
43
+ async checkAccountsHealth() {
44
+ return this.request("GET", "/api/v1/accounts/health");
45
+ }
46
+ // Posts
47
+ async createPost(data) {
48
+ return this.request("POST", "/api/v1/posts", data);
49
+ }
50
+ async listPosts(params) {
51
+ return this.request("GET", "/api/v1/posts", void 0, params);
52
+ }
53
+ async getPost(id) {
54
+ return this.request("GET", `/api/v1/posts/${id}`);
55
+ }
56
+ async updatePost(id, data) {
57
+ return this.request("PUT", `/api/v1/posts/${id}`, data);
58
+ }
59
+ async deletePost(id) {
60
+ return this.request("DELETE", `/api/v1/posts/${id}`);
61
+ }
62
+ async retryPost(id) {
63
+ return this.request("POST", `/api/v1/posts/${id}/retry`);
64
+ }
65
+ // Analytics
66
+ async getAnalytics(params) {
67
+ return this.request("GET", "/api/v1/analytics", void 0, params);
68
+ }
69
+ // Inbox
70
+ async listConversations(params) {
71
+ return this.request("GET", "/api/v1/inbox/conversations", void 0, params);
72
+ }
73
+ async getConversation(id) {
74
+ return this.request("GET", `/api/v1/inbox/conversations/${id}`);
75
+ }
76
+ async replyToConversation(id, message) {
77
+ return this.request("POST", `/api/v1/inbox/conversations/${id}/reply`, {
78
+ message
79
+ });
80
+ }
81
+ async listComments(params) {
82
+ return this.request("GET", "/api/v1/inbox/comments", void 0, params);
83
+ }
84
+ async replyToComment(id, message) {
85
+ return this.request("POST", `/api/v1/inbox/comments/${id}/reply`, {
86
+ message
87
+ });
88
+ }
89
+ async listReviews(params) {
90
+ return this.request("GET", "/api/v1/inbox/reviews", void 0, params);
91
+ }
92
+ async replyToReview(id, message) {
93
+ return this.request("POST", `/api/v1/inbox/reviews/${id}/reply`, {
94
+ message
95
+ });
96
+ }
97
+ // Media
98
+ async uploadMediaMultipart(filename, buffer, mimeType) {
99
+ const formData = new FormData();
100
+ formData.append("file", new Blob([buffer], { type: mimeType }), filename);
101
+ const url = new URL(`${this.baseUrl}/api/v1/media/upload`);
102
+ const res = await fetch(url, {
103
+ method: "POST",
104
+ headers: { Authorization: `Bearer ${this.apiKey}` },
105
+ body: formData
106
+ });
107
+ if (!res.ok) {
108
+ const errorBody = await res.json().catch(() => ({ message: res.statusText }));
109
+ const message = typeof errorBody === "object" && errorBody !== null && "message" in errorBody ? String(errorBody.message) : `API error (${res.status})`;
110
+ throw new Error(`BuzzPoster API error (${res.status}): ${message}`);
111
+ }
112
+ const text = await res.text();
113
+ return text ? JSON.parse(text) : void 0;
114
+ }
115
+ async uploadFromUrl(data) {
116
+ return this.request("POST", "/api/v1/media/upload-from-url", data);
117
+ }
118
+ async getUploadUrl(data) {
119
+ return this.request("POST", "/api/v1/media/presign", data);
120
+ }
121
+ async listMedia() {
122
+ return this.request("GET", "/api/v1/media");
123
+ }
124
+ async deleteMedia(key) {
125
+ return this.request("DELETE", `/api/v1/media/${encodeURIComponent(key)}`);
126
+ }
127
+ // Newsletter
128
+ async listSubscribers(params) {
129
+ return this.request(
130
+ "GET",
131
+ "/api/v1/newsletters/subscribers",
132
+ void 0,
133
+ params
134
+ );
135
+ }
136
+ async addSubscriber(data) {
137
+ return this.request("POST", "/api/v1/newsletters/subscribers", data);
138
+ }
139
+ async listBroadcasts(params) {
140
+ return this.request(
141
+ "GET",
142
+ "/api/v1/newsletters/broadcasts",
143
+ void 0,
144
+ params
145
+ );
146
+ }
147
+ async createBroadcast(data) {
148
+ return this.request("POST", "/api/v1/newsletters/broadcasts", data);
149
+ }
150
+ async updateBroadcast(id, data) {
151
+ return this.request("PUT", `/api/v1/newsletters/broadcasts/${id}`, data);
152
+ }
153
+ async sendBroadcast(id) {
154
+ return this.request("POST", `/api/v1/newsletters/broadcasts/${id}/send`);
155
+ }
156
+ async listTags() {
157
+ return this.request("GET", "/api/v1/newsletters/tags");
158
+ }
159
+ async listSequences() {
160
+ return this.request("GET", "/api/v1/newsletters/sequences");
161
+ }
162
+ async listForms() {
163
+ return this.request("GET", "/api/v1/newsletters/forms");
164
+ }
165
+ // Newsletter Advanced
166
+ async getSubscriberByEmail(email) {
167
+ return this.request("GET", `/api/v1/newsletters/subscribers/by-email/${encodeURIComponent(email)}`);
168
+ }
169
+ async listAutomations(params) {
170
+ return this.request("GET", "/api/v1/newsletters/automations", void 0, params);
171
+ }
172
+ async enrollInAutomation(automationId, data) {
173
+ return this.request("POST", `/api/v1/newsletters/automations/${automationId}/enroll`, data);
174
+ }
175
+ async listSegments(params) {
176
+ return this.request("GET", "/api/v1/newsletters/segments", void 0, params);
177
+ }
178
+ async getSegment(id, params) {
179
+ return this.request("GET", `/api/v1/newsletters/segments/${id}`, void 0, params);
180
+ }
181
+ async getSegmentMembers(segmentId, params) {
182
+ return this.request("GET", `/api/v1/newsletters/segments/${segmentId}/members`, void 0, params);
183
+ }
184
+ async deleteSegment(id) {
185
+ return this.request("DELETE", `/api/v1/newsletters/segments/${id}`);
186
+ }
187
+ async recalculateSegment(id) {
188
+ return this.request("POST", `/api/v1/newsletters/segments/${id}/recalculate`);
189
+ }
190
+ async listCustomFields() {
191
+ return this.request("GET", "/api/v1/newsletters/custom-fields");
192
+ }
193
+ async createCustomField(data) {
194
+ return this.request("POST", "/api/v1/newsletters/custom-fields", data);
195
+ }
196
+ async updateCustomField(id, data) {
197
+ return this.request("PUT", `/api/v1/newsletters/custom-fields/${id}`, data);
198
+ }
199
+ async deleteCustomField(id) {
200
+ return this.request("DELETE", `/api/v1/newsletters/custom-fields/${id}`);
201
+ }
202
+ async tagSubscriber(subscriberId, data) {
203
+ return this.request("POST", `/api/v1/newsletters/subscribers/${subscriberId}/tags`, data);
204
+ }
205
+ async updateSubscriber(id, data) {
206
+ return this.request("PUT", `/api/v1/newsletters/subscribers/${id}`, data);
207
+ }
208
+ async deleteSubscriber(id) {
209
+ return this.request("DELETE", `/api/v1/newsletters/subscribers/${id}`);
210
+ }
211
+ async bulkCreateSubscribers(data) {
212
+ return this.request("POST", "/api/v1/newsletters/subscribers/bulk", data);
213
+ }
214
+ async getReferralProgram() {
215
+ return this.request("GET", "/api/v1/newsletters/referral-program");
216
+ }
217
+ async listEspTiers() {
218
+ return this.request("GET", "/api/v1/newsletters/tiers");
219
+ }
220
+ async listPostTemplates() {
221
+ return this.request("GET", "/api/v1/newsletters/post-templates");
222
+ }
223
+ async getPostAggregateStats() {
224
+ return this.request("GET", "/api/v1/newsletters/broadcasts/aggregate-stats");
225
+ }
226
+ async deleteBroadcast(id) {
227
+ return this.request("DELETE", `/api/v1/newsletters/broadcasts/${id}`);
228
+ }
229
+ async listEspWebhooks() {
230
+ return this.request("GET", "/api/v1/newsletters/webhooks");
231
+ }
232
+ async createEspWebhook(data) {
233
+ return this.request("POST", "/api/v1/newsletters/webhooks", data);
234
+ }
235
+ async deleteEspWebhook(id) {
236
+ return this.request("DELETE", `/api/v1/newsletters/webhooks/${id}`);
237
+ }
238
+ // Knowledge
239
+ async listKnowledge(params) {
240
+ return this.request("GET", "/api/v1/knowledge", void 0, params);
241
+ }
242
+ async createKnowledge(data) {
243
+ return this.request("POST", "/api/v1/knowledge", data);
244
+ }
245
+ // Brand Voice
246
+ async getBrandVoice() {
247
+ return this.request("GET", "/api/v1/brand-voice");
248
+ }
249
+ async updateBrandVoice(data) {
250
+ return this.request("PUT", "/api/v1/brand-voice", data);
251
+ }
252
+ // Audiences
253
+ async getAudience(id) {
254
+ return this.request("GET", `/api/v1/audiences/${id}`);
255
+ }
256
+ async getDefaultAudience() {
257
+ return this.request("GET", "/api/v1/audiences/default");
258
+ }
259
+ async createAudience(data) {
260
+ return this.request("POST", "/api/v1/audiences", data);
261
+ }
262
+ async updateAudience(id, data) {
263
+ return this.request("PUT", `/api/v1/audiences/${id}`, data);
264
+ }
265
+ // Newsletter Templates
266
+ async getTemplate(id) {
267
+ if (id) return this.request("GET", `/api/v1/templates/${id}`);
268
+ return this.request("GET", "/api/v1/templates/default");
269
+ }
270
+ async listTemplates() {
271
+ return this.request("GET", "/api/v1/templates");
272
+ }
273
+ // Newsletter Archive
274
+ async listNewsletterArchive(params) {
275
+ return this.request("GET", "/api/v1/newsletter-archive", void 0, params);
276
+ }
277
+ async getArchivedNewsletter(id) {
278
+ return this.request("GET", `/api/v1/newsletter-archive/${id}`);
279
+ }
280
+ async saveNewsletterToArchive(data) {
281
+ return this.request("POST", "/api/v1/newsletter-archive", data);
282
+ }
283
+ // Calendar
284
+ async getCalendar(params) {
285
+ return this.request("GET", "/api/v1/calendar", void 0, params);
286
+ }
287
+ async rescheduleCalendarItem(id, scheduledFor) {
288
+ return this.request("PUT", `/api/v1/calendar/${id}`, {
289
+ scheduled_for: scheduledFor
290
+ });
291
+ }
292
+ async cancelCalendarItem(id) {
293
+ return this.request("DELETE", `/api/v1/calendar/${id}`);
294
+ }
295
+ // Queue
296
+ async getQueue() {
297
+ return this.request("GET", "/api/v1/queue");
298
+ }
299
+ async updateQueue(data) {
300
+ return this.request("PUT", "/api/v1/queue", data);
301
+ }
302
+ async getNextSlot() {
303
+ return this.request("GET", "/api/v1/queue/next");
304
+ }
305
+ // RSS
306
+ async fetchFeed(url, limit) {
307
+ const params = { url };
308
+ if (limit !== void 0) params.limit = String(limit);
309
+ return this.request("GET", "/api/v1/rss/fetch", void 0, params);
310
+ }
311
+ async fetchArticle(url) {
312
+ return this.request("GET", "/api/v1/rss/article", void 0, { url });
313
+ }
314
+ // Notifications
315
+ async getNotifications(params) {
316
+ return this.request("GET", "/api/v1/notifications", void 0, params);
317
+ }
318
+ async markNotificationRead(id) {
319
+ return this.request("PUT", `/api/v1/notifications/${id}/read`);
320
+ }
321
+ async markAllNotificationsRead() {
322
+ return this.request("PUT", "/api/v1/notifications/read-all");
323
+ }
324
+ // Content Sources
325
+ async listSources(params) {
326
+ return this.request("GET", "/api/v1/content-sources", void 0, params);
327
+ }
328
+ async createSource(data) {
329
+ return this.request("POST", "/api/v1/content-sources", data);
330
+ }
331
+ async updateSource(id, data) {
332
+ return this.request("PUT", `/api/v1/content-sources/${id}`, data);
333
+ }
334
+ async deleteSource(id) {
335
+ return this.request("DELETE", `/api/v1/content-sources/${id}`);
336
+ }
337
+ async checkSource(id) {
338
+ return this.request("POST", `/api/v1/content-sources/${id}/check`);
339
+ }
340
+ // Publishing Rules
341
+ async getPublishingRules() {
342
+ return this.request("GET", "/api/v1/publishing-rules");
343
+ }
344
+ // Audit Log
345
+ async getAuditLog(params) {
346
+ return this.request("GET", "/api/v1/audit-log", void 0, params);
347
+ }
348
+ // Newsletter Validation
349
+ async validateNewsletter(data) {
350
+ return this.request("POST", "/api/v1/newsletters/validate", data);
351
+ }
352
+ // Newsletter Schedule
353
+ async scheduleBroadcast(id, data) {
354
+ return this.request("POST", `/api/v1/newsletters/broadcasts/${id}/schedule`, data);
355
+ }
356
+ // Newsletter Test
357
+ async testBroadcast(id, data) {
358
+ return this.request("POST", `/api/v1/newsletters/broadcasts/${id}/test`, data);
359
+ }
360
+ // Carousel templates
361
+ async getCarouselTemplate() {
362
+ return this.request("GET", "/api/v1/carousel-templates");
363
+ }
364
+ async updateCarouselTemplate(data) {
365
+ return this.request("PUT", "/api/v1/carousel-templates", data);
366
+ }
367
+ async deleteCarouselTemplate() {
368
+ return this.request("DELETE", "/api/v1/carousel-templates");
369
+ }
370
+ async generateCarousel(data) {
371
+ return this.request("POST", "/api/v1/carousel-templates/generate", data);
372
+ }
373
+ async previewCarousel(data) {
374
+ return this.request("POST", "/api/v1/carousel-templates/preview", data);
375
+ }
376
+ };
377
+
378
+ // src/tools/posts.ts
379
+ import { z } from "zod";
380
+ function registerPostTools(server, client) {
381
+ server.tool(
382
+ "post",
383
+ `Create and publish a post to one or more social media platforms. Supports Twitter, Instagram, LinkedIn, and Facebook.
384
+
385
+ Always show the user a preview of the content before calling this tool. Call get_brand_voice and get_audience before composing if not already loaded this session. Never call with confirmed=true without explicit user approval.
386
+
387
+ REQUIRED WORKFLOW:
388
+ 1. BEFORE creating any content, call get_brand_voice and get_audience to match the customer's tone
389
+ 2. BEFORE publishing, call get_publishing_rules to check safety settings
390
+ 3. ALWAYS set confirmed=false first to get a preview -- never set confirmed=true on the first call
391
+ 4. Show the user a visual preview of the post before confirming
392
+ 5. Only set confirmed=true after the user explicitly says to publish/post/send
393
+ 6. If publishing_rules.social_default_action is 'draft', create as draft unless the user explicitly asks to publish
394
+ 7. If publishing_rules.require_double_confirm_social is true, ask for confirmation twice before publishing
395
+ 8. NEVER publish immediately without user confirmation, even if the user says "post this" -- always show the preview first
396
+
397
+ When confirmed=false, this tool returns a structured preview with content, platform details, character counts, safety checks, and a confirmation message. You MUST render this as a visual preview for the user and wait for their explicit approval before calling again with confirmed=true.
398
+
399
+ IMPORTANT: Always show the user a preview of the post content before publishing. Never publish without explicit user approval. If no preview has been shown yet, do NOT set confirmed=true -- render the preview first.`,
400
+ {
401
+ content: z.string().optional().describe("The text content of the post"),
402
+ platforms: z.array(z.string()).describe('Platforms to post to, e.g. ["twitter", "linkedin"]'),
403
+ media_urls: z.array(z.string()).optional().describe("Public URLs of media to attach"),
404
+ platform_specific: z.record(z.record(z.unknown())).optional().describe(
405
+ "Platform-specific data keyed by platform name. E.g. { twitter: { threadItems: [...] }, instagram: { firstComment: '...' } }"
406
+ ),
407
+ confirmed: z.boolean().default(false).describe(
408
+ "Set to true to confirm and publish. If false or missing, returns a preview for user approval."
409
+ )
410
+ },
411
+ {
412
+ title: "Publish Post Now",
413
+ readOnlyHint: false,
414
+ destructiveHint: false,
415
+ idempotentHint: false,
416
+ openWorldHint: true
417
+ },
418
+ async (args) => {
419
+ if (args.confirmed !== true) {
420
+ let rulesText = "";
421
+ try {
422
+ const rules = await client.getPublishingRules();
423
+ const blockedWords = rules.blockedWords ?? [];
424
+ const foundBlocked = [];
425
+ if (args.content && blockedWords.length > 0) {
426
+ const lower = args.content.toLowerCase();
427
+ for (const word of blockedWords) {
428
+ if (lower.includes(word.toLowerCase())) foundBlocked.push(word);
429
+ }
430
+ }
431
+ rulesText = `
432
+ ### Safety Checks
433
+ `;
434
+ rulesText += `- Blocked words: ${foundBlocked.length > 0 ? `**FAILED** (found: ${foundBlocked.join(", ")})` : "PASS"}
435
+ `;
436
+ rulesText += `- Default action: ${rules.socialDefaultAction ?? "draft"}
437
+ `;
438
+ rulesText += `- Immediate publish allowed: ${rules.allowImmediatePublish ? "yes" : "no"}
439
+ `;
440
+ if (rules.maxPostsPerDay) rulesText += `- Daily post limit: ${rules.maxPostsPerDay}
441
+ `;
442
+ } catch {
443
+ }
444
+ const charCounts = {};
445
+ const limits = { twitter: 280, linkedin: 3e3, instagram: 2200, facebook: 63206, threads: 500 };
446
+ const contentLen = (args.content ?? "").length;
447
+ for (const p of args.platforms) {
448
+ const limit = limits[p.toLowerCase()] ?? 5e3;
449
+ charCounts[p] = { used: contentLen, limit, ok: contentLen <= limit };
450
+ }
451
+ let charText = "\n### Character Counts\n";
452
+ for (const [platform, counts] of Object.entries(charCounts)) {
453
+ charText += `- ${platform}: ${counts.used}/${counts.limit} ${counts.ok ? "" : "**OVER LIMIT**"}
454
+ `;
455
+ }
456
+ const preview = `## Post Preview
457
+
458
+ **Content:** "${args.content ?? "(no text)"}"
459
+ **Platforms:** ${args.platforms.join(", ")}
460
+ ` + (args.media_urls?.length ? `**Media:** ${args.media_urls.length} file(s)
461
+ ` : "") + charText + rulesText + `
462
+ **Action:** This will be published **immediately** to ${args.platforms.length} platform(s).
463
+
464
+ Call this tool again with confirmed=true to proceed.`;
465
+ return { content: [{ type: "text", text: preview }] };
466
+ }
467
+ const platforms = args.platforms.map((platform) => {
468
+ const entry = { platform };
469
+ if (args.platform_specific?.[platform]) {
470
+ entry.platformSpecificData = args.platform_specific[platform];
471
+ }
472
+ return entry;
473
+ });
474
+ const body = {
475
+ content: args.content,
476
+ platforms,
477
+ publishNow: true
478
+ };
479
+ if (args.media_urls?.length) {
480
+ body.mediaItems = args.media_urls.map((url) => ({
481
+ type: url.match(/\.(mp4|mov|avi|webm)$/i) ? "video" : "image",
482
+ url
483
+ }));
484
+ }
485
+ const result = await client.createPost(body);
486
+ return {
487
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
488
+ };
489
+ }
490
+ );
491
+ server.tool(
492
+ "cross_post",
493
+ `Post the same content to all connected platforms at once.
494
+
495
+ Always show the user a preview of the content before calling this tool. Call get_brand_voice and get_audience before composing if not already loaded this session. Never call with confirmed=true without explicit user approval.
496
+
497
+ REQUIRED WORKFLOW:
498
+ 1. BEFORE creating content, call get_brand_voice and get_audience
499
+ 2. BEFORE publishing, call get_publishing_rules
500
+ 3. ALWAYS set confirmed=false first to preview
501
+ 4. Show the user a visual preview showing how the post will appear on each platform
502
+ 5. This is a high-impact action (goes to ALL platforms) -- always require explicit confirmation
503
+ 6. If publishing_rules.require_double_confirm_social is true, ask twice
504
+
505
+ When confirmed=false, this tool returns a structured preview with content, platform details, character counts, safety checks, and a confirmation message. You MUST render this as a visual preview for the user and wait for their explicit approval before calling again with confirmed=true.`,
506
+ {
507
+ content: z.string().describe("The text content to post everywhere"),
508
+ media_urls: z.array(z.string()).optional().describe("Public URLs of media to attach"),
509
+ confirmed: z.boolean().default(false).describe(
510
+ "Set to true to confirm and publish. If false or missing, returns a preview for user approval."
511
+ )
512
+ },
513
+ {
514
+ title: "Cross-Post to All Platforms",
515
+ readOnlyHint: false,
516
+ destructiveHint: false,
517
+ idempotentHint: false,
518
+ openWorldHint: true
519
+ },
520
+ async (args) => {
521
+ const accountsData = await client.listAccounts();
522
+ const platforms = (accountsData.accounts ?? []).map(
523
+ (a) => a.platform
524
+ );
525
+ if (platforms.length === 0) {
526
+ return {
527
+ content: [
528
+ {
529
+ type: "text",
530
+ text: "No connected social accounts found. Connect accounts first."
531
+ }
532
+ ]
533
+ };
534
+ }
535
+ if (args.confirmed !== true) {
536
+ let rulesText = "";
537
+ try {
538
+ const rules = await client.getPublishingRules();
539
+ const blockedWords = rules.blockedWords ?? [];
540
+ const foundBlocked = [];
541
+ if (args.content && blockedWords.length > 0) {
542
+ const lower = args.content.toLowerCase();
543
+ for (const word of blockedWords) {
544
+ if (lower.includes(word.toLowerCase())) foundBlocked.push(word);
545
+ }
546
+ }
547
+ rulesText = `
548
+ ### Safety Checks
549
+ `;
550
+ rulesText += `- Blocked words: ${foundBlocked.length > 0 ? `**FAILED** (found: ${foundBlocked.join(", ")})` : "PASS"}
551
+ `;
552
+ rulesText += `- Default action: ${rules.socialDefaultAction ?? "draft"}
553
+ `;
554
+ rulesText += `- Double confirmation required: ${rules.requireDoubleConfirmSocial ? "yes" : "no"}
555
+ `;
556
+ } catch {
557
+ }
558
+ const preview = `## Cross-Post Preview
559
+
560
+ **Content:** "${args.content}"
561
+ **Platforms:** ${platforms.join(", ")} (${platforms.length} platforms)
562
+ ` + (args.media_urls?.length ? `**Media:** ${args.media_urls.length} file(s)
563
+ ` : "") + rulesText + `
564
+ **Action:** This will be published **immediately** to **all ${platforms.length} connected platforms**. This is a high-impact action.
565
+
566
+ Call this tool again with confirmed=true to proceed.`;
567
+ return { content: [{ type: "text", text: preview }] };
568
+ }
569
+ const body = {
570
+ content: args.content,
571
+ platforms: platforms.map((p) => ({ platform: p })),
572
+ publishNow: true
573
+ };
574
+ if (args.media_urls?.length) {
575
+ body.mediaItems = args.media_urls.map((url) => ({
576
+ type: url.match(/\.(mp4|mov|avi|webm)$/i) ? "video" : "image",
577
+ url
578
+ }));
579
+ }
580
+ const result = await client.createPost(body);
581
+ return {
582
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
583
+ };
584
+ }
585
+ );
586
+ server.tool(
587
+ "schedule_post",
588
+ `Schedule a post for future publication.
589
+
590
+ Always show the user a preview of the content before calling this tool. Call get_brand_voice and get_audience before composing if not already loaded this session. Never call with confirmed=true without explicit user approval.
591
+
592
+ REQUIRED WORKFLOW:
593
+ 1. BEFORE creating content, call get_brand_voice and get_audience
594
+ 2. BEFORE scheduling, call get_publishing_rules
595
+ 3. ALWAYS set confirmed=false first to preview
596
+ 4. Show the user a visual preview with the scheduled date/time before confirming
597
+ 5. Only confirm after explicit user approval
598
+ 6. If the user hasn't specified a time, suggest one based on get_queue time slots
599
+
600
+ When confirmed=false, this tool returns a structured preview with content, platform details, character counts, safety checks, and a confirmation message. You MUST render this as a visual preview for the user and wait for their explicit approval before calling again with confirmed=true.
601
+
602
+ IMPORTANT: Always show the user a preview of the post content before scheduling. Never schedule without explicit user approval. If no preview has been shown yet, do NOT set confirmed=true -- render the preview first.`,
603
+ {
604
+ content: z.string().optional().describe("The text content of the post"),
605
+ platforms: z.array(z.string()).describe('Platforms to post to, e.g. ["twitter", "linkedin"]'),
606
+ scheduled_for: z.string().describe("ISO 8601 datetime for when to publish, e.g. 2024-01-16T12:00:00"),
607
+ timezone: z.string().default("UTC").describe("Timezone for the scheduled time, e.g. America/New_York"),
608
+ media_urls: z.array(z.string()).optional().describe("Media URLs to attach"),
609
+ platform_specific: z.record(z.record(z.unknown())).optional().describe("Platform-specific data keyed by platform name"),
610
+ confirmed: z.boolean().default(false).describe(
611
+ "Set to true to confirm scheduling. If false or missing, returns a preview for user approval."
612
+ )
613
+ },
614
+ {
615
+ title: "Schedule Social Post",
616
+ readOnlyHint: false,
617
+ destructiveHint: false,
618
+ idempotentHint: false,
619
+ openWorldHint: true
620
+ },
621
+ async (args) => {
622
+ if (args.confirmed !== true) {
623
+ let rulesText = "";
624
+ try {
625
+ const rules = await client.getPublishingRules();
626
+ const blockedWords = rules.blockedWords ?? [];
627
+ const foundBlocked = [];
628
+ if (args.content && blockedWords.length > 0) {
629
+ const lower = args.content.toLowerCase();
630
+ for (const word of blockedWords) {
631
+ if (lower.includes(word.toLowerCase())) foundBlocked.push(word);
632
+ }
633
+ }
634
+ rulesText = `
635
+ ### Safety Checks
636
+ `;
637
+ rulesText += `- Blocked words: ${foundBlocked.length > 0 ? `**FAILED** (found: ${foundBlocked.join(", ")})` : "PASS"}
638
+ `;
639
+ } catch {
640
+ }
641
+ const preview = `## Schedule Preview
642
+
643
+ **Content:** "${args.content ?? "(no text)"}"
644
+ **Platforms:** ${args.platforms.join(", ")}
645
+ **Scheduled for:** ${args.scheduled_for} (${args.timezone})
646
+ ` + (args.media_urls?.length ? `**Media:** ${args.media_urls.length} file(s)
647
+ ` : "") + rulesText + `
648
+ Call this tool again with confirmed=true to schedule.`;
649
+ return { content: [{ type: "text", text: preview }] };
650
+ }
651
+ const platforms = args.platforms.map((platform) => {
652
+ const entry = { platform };
653
+ if (args.platform_specific?.[platform]) {
654
+ entry.platformSpecificData = args.platform_specific[platform];
655
+ }
656
+ return entry;
657
+ });
658
+ const body = {
659
+ content: args.content,
660
+ platforms,
661
+ scheduledFor: args.scheduled_for,
662
+ timezone: args.timezone
663
+ };
664
+ if (args.media_urls?.length) {
665
+ body.mediaItems = args.media_urls.map((url) => ({
666
+ type: url.match(/\.(mp4|mov|avi|webm)$/i) ? "video" : "image",
667
+ url
668
+ }));
669
+ }
670
+ const result = await client.createPost(body);
671
+ return {
672
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
673
+ };
674
+ }
675
+ );
676
+ server.tool(
677
+ "create_draft",
678
+ "Save a post as a draft without publishing. This NEVER publishes \u2014 it only saves. No confirmation needed since nothing goes live.",
679
+ {
680
+ content: z.string().optional().describe("The text content of the draft"),
681
+ platforms: z.array(z.string()).describe("Platforms this draft is intended for"),
682
+ media_urls: z.array(z.string()).optional().describe("Media URLs to attach")
683
+ },
684
+ {
685
+ title: "Create Draft Post",
686
+ readOnlyHint: false,
687
+ destructiveHint: false,
688
+ idempotentHint: false,
689
+ openWorldHint: false
690
+ },
691
+ async (args) => {
692
+ const body = {
693
+ content: args.content,
694
+ platforms: args.platforms.map((p) => ({ platform: p }))
695
+ };
696
+ if (args.media_urls?.length) {
697
+ body.mediaItems = args.media_urls.map((url) => ({
698
+ type: url.match(/\.(mp4|mov|avi|webm)$/i) ? "video" : "image",
699
+ url
700
+ }));
701
+ }
702
+ const result = await client.createPost(body);
703
+ return {
704
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
705
+ };
706
+ }
707
+ );
708
+ server.tool(
709
+ "list_posts",
710
+ "List recent posts with their status and platform details.",
711
+ {
712
+ status: z.string().optional().describe("Filter by status: published, scheduled, draft, failed"),
713
+ limit: z.string().optional().describe("Number of posts to return")
714
+ },
715
+ {
716
+ title: "List Posts",
717
+ readOnlyHint: true,
718
+ destructiveHint: false,
719
+ idempotentHint: true,
720
+ openWorldHint: true
721
+ },
722
+ async (args) => {
723
+ const params = {};
724
+ if (args.status) params.status = args.status;
725
+ if (args.limit) params.limit = args.limit;
726
+ const result = await client.listPosts(params);
727
+ return {
728
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
729
+ };
730
+ }
731
+ );
732
+ server.tool(
733
+ "get_post",
734
+ "Get detailed information about a specific post.",
735
+ {
736
+ post_id: z.string().describe("The ID of the post to retrieve")
737
+ },
738
+ {
739
+ title: "Get Post Details",
740
+ readOnlyHint: true,
741
+ destructiveHint: false,
742
+ idempotentHint: true,
743
+ openWorldHint: true
744
+ },
745
+ async (args) => {
746
+ const result = await client.getPost(args.post_id);
747
+ return {
748
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
749
+ };
750
+ }
751
+ );
752
+ server.tool(
753
+ "retry_post",
754
+ "Retry a post that failed to publish on one or more platforms. Only retries the failed platforms -- already-published platforms are not affected. Use this when a post shows 'failed' or 'partial' status.",
755
+ {
756
+ post_id: z.string().describe("The ID of the post to retry"),
757
+ confirmed: z.boolean().default(false).describe(
758
+ "Set to true to confirm retry. If false/missing, shows post details first."
759
+ )
760
+ },
761
+ {
762
+ title: "Retry Failed Post",
763
+ readOnlyHint: false,
764
+ destructiveHint: false,
765
+ idempotentHint: true,
766
+ openWorldHint: true
767
+ },
768
+ async (args) => {
769
+ if (args.confirmed !== true) {
770
+ const post = await client.getPost(args.post_id);
771
+ const platforms = post.platforms ?? [];
772
+ const failed = platforms.filter((p) => p.status === "failed");
773
+ const published = platforms.filter((p) => p.status === "published");
774
+ let text = `## Retry Post Preview
775
+
776
+ `;
777
+ text += `**Post ID:** ${args.post_id}
778
+ `;
779
+ text += `**Content:** "${(post.content ?? "").substring(0, 100)}"
780
+ `;
781
+ text += `**Status:** ${post.status}
782
+
783
+ `;
784
+ if (published.length > 0) {
785
+ text += `**Already published on:** ${published.map((p) => p.platform).join(", ")}
786
+ `;
787
+ }
788
+ if (failed.length > 0) {
789
+ text += `**Failed on:** ${failed.map((p) => `${p.platform} (${p.error ?? "unknown error"})`).join(", ")}
790
+ `;
791
+ }
792
+ text += `
793
+ Retrying will only attempt the failed platforms. Call this tool again with confirmed=true to proceed.`;
794
+ return { content: [{ type: "text", text }] };
795
+ }
796
+ const result = await client.retryPost(args.post_id);
797
+ return {
798
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
799
+ };
800
+ }
801
+ );
802
+ }
803
+
804
+ // src/tools/accounts.ts
805
+ function registerAccountTools(server, client) {
806
+ server.tool(
807
+ "list_accounts",
808
+ "List all connected accounts \u2014 both social media platforms (Twitter, Instagram, etc.) and email service provider / ESP (Kit, Beehiiv, Mailchimp). Use this whenever the user asks about their accounts, connections, or integrations.",
809
+ {},
810
+ {
811
+ title: "List Connected Accounts",
812
+ readOnlyHint: true,
813
+ destructiveHint: false,
814
+ idempotentHint: true,
815
+ openWorldHint: false
816
+ },
817
+ async () => {
818
+ const result = await client.listAccounts();
819
+ const accounts = result.accounts ?? [];
820
+ const esp = result.esp;
821
+ if (accounts.length === 0 && !esp) {
822
+ return {
823
+ content: [{
824
+ type: "text",
825
+ text: "## Connected Accounts\n\nNo accounts connected. Connect social media accounts and/or an email service provider via the dashboard."
826
+ }]
827
+ };
828
+ }
829
+ let text = "## Connected Accounts\n\n";
830
+ text += "### Social Media\n";
831
+ if (accounts.length > 0) {
832
+ for (const a of accounts) {
833
+ const icon = a.isActive ? "\u2705" : "\u274C";
834
+ const platform = a.platform.charAt(0).toUpperCase() + a.platform.slice(1);
835
+ const name = a.username || a.displayName || a.platform;
836
+ text += `${icon} **${platform}** \u2014 @${name}
837
+ `;
838
+ }
839
+ } else {
840
+ text += "No social accounts connected.\n";
841
+ }
842
+ text += "\n### Email Service Provider (ESP)\n";
843
+ if (esp && esp.connected) {
844
+ const provider = esp.provider.charAt(0).toUpperCase() + esp.provider.slice(1);
845
+ text += `\u2705 **${provider}** \u2014 Connected`;
846
+ if (esp.publicationId) text += ` (Publication: ${esp.publicationId})`;
847
+ text += "\n";
848
+ } else if (esp) {
849
+ const provider = esp.provider.charAt(0).toUpperCase() + esp.provider.slice(1);
850
+ text += `\u26A0\uFE0F **${provider}** \u2014 Provider set but API key missing
851
+ `;
852
+ } else {
853
+ text += "No ESP configured.\n";
854
+ }
855
+ return {
856
+ content: [{ type: "text", text }]
857
+ };
858
+ }
859
+ );
860
+ server.tool(
861
+ "check_accounts_health",
862
+ "Check the health status of all connected social media accounts. Shows which accounts are working, which have warnings, and which need reconnection. Call this before scheduling posts to make sure target platforms are healthy.",
863
+ {},
864
+ {
865
+ title: "Check Account Health",
866
+ readOnlyHint: true,
867
+ destructiveHint: false,
868
+ idempotentHint: true,
869
+ openWorldHint: true
870
+ },
871
+ async () => {
872
+ const result = await client.checkAccountsHealth();
873
+ const accounts = result.accounts ?? [];
874
+ const summary = result.summary;
875
+ if (accounts.length === 0) {
876
+ return {
877
+ content: [{
878
+ type: "text",
879
+ text: "## Account Health\n\nNo connected accounts found. Connect accounts first via the dashboard."
880
+ }]
881
+ };
882
+ }
883
+ const statusIcon = {
884
+ healthy: "\u2705",
885
+ warning: "\u26A0\uFE0F",
886
+ error: "\u274C"
887
+ };
888
+ const lines = accounts.map((a) => {
889
+ const icon = statusIcon[a.status] ?? "\u2753";
890
+ const name = a.username ? `@${a.username}` : a.platform;
891
+ const platform = a.platform.charAt(0).toUpperCase() + a.platform.slice(1);
892
+ const msg = a.message || (a.status === "healthy" ? "Healthy" : a.status);
893
+ return `${icon} **${platform}** (${name}) \u2014 ${msg}`;
894
+ });
895
+ let text = `## Account Health
896
+
897
+ ${lines.join("\n")}`;
898
+ if (summary) {
899
+ text += `
900
+
901
+ **Summary:** ${summary.total} total, ${summary.healthy} healthy, ${summary.warning} warning, ${summary.error} error`;
902
+ }
903
+ return {
904
+ content: [{ type: "text", text }]
905
+ };
906
+ }
907
+ );
908
+ }
909
+
910
+ // src/tools/analytics.ts
911
+ import { z as z2 } from "zod";
912
+ function registerAnalyticsTools(server, client) {
913
+ server.tool(
914
+ "get_analytics",
915
+ "Get performance analytics for your social media posts. Supports filtering by platform and date range.",
916
+ {
917
+ platform: z2.string().optional().describe("Filter by platform: twitter, instagram, linkedin, facebook"),
918
+ from_date: z2.string().optional().describe("Start date for analytics range (ISO 8601)"),
919
+ to_date: z2.string().optional().describe("End date for analytics range (ISO 8601)")
920
+ },
921
+ {
922
+ title: "Get Analytics",
923
+ readOnlyHint: true,
924
+ destructiveHint: false,
925
+ idempotentHint: true,
926
+ openWorldHint: true
927
+ },
928
+ async (args) => {
929
+ const params = {};
930
+ if (args.platform) params.platform = args.platform;
931
+ if (args.from_date) params.fromDate = args.from_date;
932
+ if (args.to_date) params.toDate = args.to_date;
933
+ const result = await client.getAnalytics(params);
934
+ return {
935
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
936
+ };
937
+ }
938
+ );
939
+ }
940
+
941
+ // src/tools/inbox.ts
942
+ import { z as z3 } from "zod";
943
+ function registerInboxTools(server, client) {
944
+ server.tool(
945
+ "list_conversations",
946
+ "List DM conversations across all connected social media platforms.",
947
+ {
948
+ platform: z3.string().optional().describe("Filter by platform: twitter, instagram, linkedin, facebook")
949
+ },
950
+ {
951
+ title: "List Conversations",
952
+ readOnlyHint: true,
953
+ destructiveHint: false,
954
+ idempotentHint: true,
955
+ openWorldHint: true
956
+ },
957
+ async (args) => {
958
+ const params = {};
959
+ if (args.platform) params.platform = args.platform;
960
+ const result = await client.listConversations(params);
961
+ return {
962
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
963
+ };
964
+ }
965
+ );
966
+ server.tool(
967
+ "get_conversation",
968
+ "Get all messages in a specific DM conversation.",
969
+ {
970
+ conversation_id: z3.string().describe("The conversation ID")
971
+ },
972
+ {
973
+ title: "Get Conversation Messages",
974
+ readOnlyHint: true,
975
+ destructiveHint: false,
976
+ idempotentHint: true,
977
+ openWorldHint: true
978
+ },
979
+ async (args) => {
980
+ const result = await client.getConversation(args.conversation_id);
981
+ return {
982
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
983
+ };
984
+ }
985
+ );
986
+ server.tool(
987
+ "reply_to_conversation",
988
+ `Send a reply message in a DM conversation. This sends a message on an external platform on the customer's behalf. Always show draft reply to the user before calling with confirmed=true.
989
+
990
+ REQUIRED WORKFLOW:
991
+ 1. ALWAYS set confirmed=false first to preview the reply
992
+ 2. Show the user the reply text and which platform/conversation it will be sent to
993
+ 3. Only confirm after explicit user approval`,
994
+ {
995
+ conversation_id: z3.string().describe("The conversation ID to reply to"),
996
+ message: z3.string().describe("The reply message text"),
997
+ confirmed: z3.boolean().default(false).describe("Set to true to confirm and send. If false or missing, returns a preview for user approval.")
998
+ },
999
+ {
1000
+ title: "Reply to Conversation",
1001
+ readOnlyHint: false,
1002
+ destructiveHint: false,
1003
+ idempotentHint: false,
1004
+ openWorldHint: true
1005
+ },
1006
+ async (args) => {
1007
+ if (args.confirmed !== true) {
1008
+ const preview = `## Reply Preview
1009
+
1010
+ **Conversation:** ${args.conversation_id}
1011
+ **Message:** "${args.message}"
1012
+
1013
+ This will send a DM reply on the external platform. Call this tool again with confirmed=true to send.`;
1014
+ return { content: [{ type: "text", text: preview }] };
1015
+ }
1016
+ const result = await client.replyToConversation(
1017
+ args.conversation_id,
1018
+ args.message
1019
+ );
1020
+ return {
1021
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1022
+ };
1023
+ }
1024
+ );
1025
+ server.tool(
1026
+ "list_comments",
1027
+ "List comments on your social media posts.",
1028
+ {
1029
+ post_id: z3.string().optional().describe("Filter comments by post ID")
1030
+ },
1031
+ {
1032
+ title: "List Comments",
1033
+ readOnlyHint: true,
1034
+ destructiveHint: false,
1035
+ idempotentHint: true,
1036
+ openWorldHint: true
1037
+ },
1038
+ async (args) => {
1039
+ const params = {};
1040
+ if (args.post_id) params.postId = args.post_id;
1041
+ const result = await client.listComments(params);
1042
+ return {
1043
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1044
+ };
1045
+ }
1046
+ );
1047
+ server.tool(
1048
+ "reply_to_comment",
1049
+ `Reply to a comment on one of the customer's posts. This sends a public reply on the external platform. Sends a message on the user's behalf on an external platform. Always show draft reply to the user before calling with confirmed=true.
1050
+
1051
+ REQUIRED WORKFLOW:
1052
+ 1. ALWAYS set confirmed=false first to preview
1053
+ 2. Show the user the reply and the original comment for context
1054
+ 3. Only confirm after explicit user approval -- public replies are visible to everyone`,
1055
+ {
1056
+ comment_id: z3.string().describe("The comment ID to reply to"),
1057
+ message: z3.string().describe("The reply text"),
1058
+ confirmed: z3.boolean().default(false).describe("Set to true to confirm and send. If false or missing, returns a preview for user approval.")
1059
+ },
1060
+ {
1061
+ title: "Reply to Comment",
1062
+ readOnlyHint: false,
1063
+ destructiveHint: false,
1064
+ idempotentHint: false,
1065
+ openWorldHint: true
1066
+ },
1067
+ async (args) => {
1068
+ if (args.confirmed !== true) {
1069
+ const preview = `## Comment Reply Preview
1070
+
1071
+ **Comment ID:** ${args.comment_id}
1072
+ **Reply:** "${args.message}"
1073
+
1074
+ This will post a public reply. Call this tool again with confirmed=true to send.`;
1075
+ return { content: [{ type: "text", text: preview }] };
1076
+ }
1077
+ const result = await client.replyToComment(
1078
+ args.comment_id,
1079
+ args.message
1080
+ );
1081
+ return {
1082
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1083
+ };
1084
+ }
1085
+ );
1086
+ server.tool(
1087
+ "list_reviews",
1088
+ "List reviews on your Facebook page.",
1089
+ {},
1090
+ {
1091
+ title: "List Reviews",
1092
+ readOnlyHint: true,
1093
+ destructiveHint: false,
1094
+ idempotentHint: true,
1095
+ openWorldHint: true
1096
+ },
1097
+ async () => {
1098
+ const result = await client.listReviews();
1099
+ return {
1100
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1101
+ };
1102
+ }
1103
+ );
1104
+ server.tool(
1105
+ "reply_to_review",
1106
+ `Reply to a review on the customer's Facebook page. This sends a public reply on the external platform. Sends a message on the user's behalf on an external platform. Always show draft reply to the user before calling with confirmed=true.
1107
+
1108
+ REQUIRED WORKFLOW:
1109
+ 1. ALWAYS set confirmed=false first to preview
1110
+ 2. Show the user the reply, the original review, and the star rating for context
1111
+ 3. Only confirm after explicit user approval -- public replies represent the brand`,
1112
+ {
1113
+ review_id: z3.string().describe("The review ID to reply to"),
1114
+ message: z3.string().describe("The reply text"),
1115
+ confirmed: z3.boolean().default(false).describe("Set to true to confirm and send. If false or missing, returns a preview for user approval.")
1116
+ },
1117
+ {
1118
+ title: "Reply to Review",
1119
+ readOnlyHint: false,
1120
+ destructiveHint: false,
1121
+ idempotentHint: false,
1122
+ openWorldHint: true
1123
+ },
1124
+ async (args) => {
1125
+ if (args.confirmed !== true) {
1126
+ const preview = `## Review Reply Preview
1127
+
1128
+ **Review ID:** ${args.review_id}
1129
+ **Reply:** "${args.message}"
1130
+
1131
+ This will post a public reply to the review. Call this tool again with confirmed=true to send.`;
1132
+ return { content: [{ type: "text", text: preview }] };
1133
+ }
1134
+ const result = await client.replyToReview(
1135
+ args.review_id,
1136
+ args.message
1137
+ );
1138
+ return {
1139
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1140
+ };
1141
+ }
1142
+ );
1143
+ }
1144
+
1145
+ // src/tools/media.ts
1146
+ import { z as z4 } from "zod";
1147
+ import { readFile } from "fs/promises";
1148
+ function registerMediaTools(server, client) {
1149
+ server.tool(
1150
+ "upload_media",
1151
+ "Upload an image or video file from a local file path. Returns a CDN URL that can be used in posts.",
1152
+ {
1153
+ file_path: z4.string().describe("Absolute path to the file on the local filesystem")
1154
+ },
1155
+ {
1156
+ title: "Upload Media File",
1157
+ readOnlyHint: false,
1158
+ destructiveHint: false,
1159
+ idempotentHint: false,
1160
+ openWorldHint: false
1161
+ },
1162
+ async (args) => {
1163
+ const buffer = Buffer.from(await readFile(args.file_path));
1164
+ const filename = args.file_path.split("/").pop() ?? "upload";
1165
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
1166
+ const mimeMap = {
1167
+ jpg: "image/jpeg",
1168
+ jpeg: "image/jpeg",
1169
+ png: "image/png",
1170
+ gif: "image/gif",
1171
+ webp: "image/webp",
1172
+ mp4: "video/mp4",
1173
+ mov: "video/quicktime",
1174
+ avi: "video/x-msvideo",
1175
+ webm: "video/webm",
1176
+ pdf: "application/pdf"
1177
+ };
1178
+ const mimeType = mimeMap[ext] ?? "application/octet-stream";
1179
+ const result = await client.uploadMediaMultipart(filename, buffer, mimeType);
1180
+ return {
1181
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1182
+ };
1183
+ }
1184
+ );
1185
+ server.tool(
1186
+ "upload_from_url",
1187
+ "Upload media from a public URL. The server fetches the image/video and uploads it to storage. Returns a CDN URL that can be used in posts. Supports JPEG, PNG, GIF, WebP, MP4, MOV, WebM up to 25MB.",
1188
+ {
1189
+ url: z4.string().url().describe("Public URL of the image or video to upload"),
1190
+ filename: z4.string().optional().describe("Optional filename override (including extension)"),
1191
+ folder: z4.string().optional().describe("Optional folder path within the customer's storage")
1192
+ },
1193
+ {
1194
+ title: "Upload Media from URL",
1195
+ readOnlyHint: false,
1196
+ destructiveHint: false,
1197
+ idempotentHint: false,
1198
+ openWorldHint: false
1199
+ },
1200
+ async (args) => {
1201
+ const result = await client.uploadFromUrl({
1202
+ url: args.url,
1203
+ filename: args.filename,
1204
+ folder: args.folder
1205
+ });
1206
+ return {
1207
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1208
+ };
1209
+ }
1210
+ );
1211
+ server.tool(
1212
+ "get_upload_url",
1213
+ "Get a pre-signed URL for direct client upload to storage. The URL is valid for 5 minutes. After uploading to the URL, the file will be available at the returned CDN URL.",
1214
+ {
1215
+ filename: z4.string().describe("The filename including extension (e.g. photo.jpg)"),
1216
+ content_type: z4.string().describe("MIME type of the file (e.g. image/jpeg, video/mp4)")
1217
+ },
1218
+ {
1219
+ title: "Get Upload URL",
1220
+ readOnlyHint: true,
1221
+ destructiveHint: false,
1222
+ idempotentHint: false,
1223
+ openWorldHint: false
1224
+ },
1225
+ async (args) => {
1226
+ const result = await client.getUploadUrl({
1227
+ filename: args.filename,
1228
+ content_type: args.content_type
1229
+ });
1230
+ return {
1231
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1232
+ };
1233
+ }
1234
+ );
1235
+ server.tool(
1236
+ "list_media",
1237
+ "List all uploaded media files in your BuzzPoster media library.",
1238
+ {},
1239
+ {
1240
+ title: "List Media Library",
1241
+ readOnlyHint: true,
1242
+ destructiveHint: false,
1243
+ idempotentHint: true,
1244
+ openWorldHint: false
1245
+ },
1246
+ async () => {
1247
+ const result = await client.listMedia();
1248
+ return {
1249
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1250
+ };
1251
+ }
1252
+ );
1253
+ server.tool(
1254
+ "delete_media",
1255
+ `Delete a media file from the customer's BuzzPoster media library. This cannot be undone.
1256
+
1257
+ REQUIRED WORKFLOW:
1258
+ 1. ALWAYS set confirmed=false first
1259
+ 2. Show the user which file will be deleted (filename, URL, size)
1260
+ 3. Only confirm after explicit approval`,
1261
+ {
1262
+ key: z4.string().describe("The key/path of the media file to delete"),
1263
+ confirmed: z4.boolean().default(false).describe("Set to true to confirm deletion. If false or missing, returns a preview for user approval.")
1264
+ },
1265
+ {
1266
+ title: "Delete Media File",
1267
+ readOnlyHint: false,
1268
+ destructiveHint: true,
1269
+ idempotentHint: true,
1270
+ openWorldHint: false
1271
+ },
1272
+ async (args) => {
1273
+ if (args.confirmed !== true) {
1274
+ const preview = `## Delete Media Confirmation
1275
+
1276
+ **File:** ${args.key}
1277
+
1278
+ This will permanently delete this media file. Call this tool again with confirmed=true to proceed.`;
1279
+ return { content: [{ type: "text", text: preview }] };
1280
+ }
1281
+ await client.deleteMedia(args.key);
1282
+ return {
1283
+ content: [{ type: "text", text: "Media deleted successfully." }]
1284
+ };
1285
+ }
1286
+ );
1287
+ }
1288
+
1289
+ // src/tools/newsletter.ts
1290
+ import { z as z5 } from "zod";
1291
+ function registerNewsletterTools(server, client, options = {}) {
1292
+ server.tool(
1293
+ "list_subscribers",
1294
+ "List email subscribers from your configured email service provider (Kit, Beehiiv, or Mailchimp).",
1295
+ {
1296
+ page: z5.string().optional().describe("Page number for pagination"),
1297
+ per_page: z5.string().optional().describe("Number of subscribers per page")
1298
+ },
1299
+ {
1300
+ title: "List Subscribers",
1301
+ readOnlyHint: true,
1302
+ destructiveHint: false,
1303
+ idempotentHint: true,
1304
+ openWorldHint: true
1305
+ },
1306
+ async (args) => {
1307
+ const params = {};
1308
+ if (args.page) params.page = args.page;
1309
+ if (args.per_page) params.perPage = args.per_page;
1310
+ const result = await client.listSubscribers(params);
1311
+ return {
1312
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1313
+ };
1314
+ }
1315
+ );
1316
+ server.tool(
1317
+ "add_subscriber",
1318
+ "Add a new email subscriber to your mailing list.",
1319
+ {
1320
+ email: z5.string().describe("Email address of the subscriber"),
1321
+ first_name: z5.string().optional().describe("Subscriber's first name"),
1322
+ last_name: z5.string().optional().describe("Subscriber's last name")
1323
+ },
1324
+ {
1325
+ title: "Add Subscriber",
1326
+ readOnlyHint: false,
1327
+ destructiveHint: false,
1328
+ idempotentHint: false,
1329
+ openWorldHint: true
1330
+ },
1331
+ async (args) => {
1332
+ const result = await client.addSubscriber({
1333
+ email: args.email,
1334
+ firstName: args.first_name,
1335
+ lastName: args.last_name
1336
+ });
1337
+ return {
1338
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1339
+ };
1340
+ }
1341
+ );
1342
+ server.tool(
1343
+ "create_newsletter",
1344
+ `Push an approved newsletter draft to the user's ESP (Kit, Beehiiv, or Mailchimp) as a DRAFT. This creates a DRAFT only -- it does not send. IMPORTANT: Only call this AFTER the user has reviewed and explicitly approved an HTML artifact preview of the newsletter. If no artifact has been shown yet, do NOT call this tool -- go back and render the preview first. No third-party branding in the output unless the customer explicitly configured it.`,
1345
+ {
1346
+ subject: z5.string().describe("Email subject line"),
1347
+ content: z5.string().describe("HTML content of the newsletter"),
1348
+ preview_text: z5.string().optional().describe("Preview text shown in email clients")
1349
+ },
1350
+ {
1351
+ title: "Create Newsletter Draft",
1352
+ readOnlyHint: false,
1353
+ destructiveHint: false,
1354
+ idempotentHint: false,
1355
+ openWorldHint: true
1356
+ },
1357
+ async (args) => {
1358
+ const result = await client.createBroadcast({
1359
+ subject: args.subject,
1360
+ content: args.content,
1361
+ previewText: args.preview_text
1362
+ });
1363
+ return {
1364
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1365
+ };
1366
+ }
1367
+ );
1368
+ server.tool(
1369
+ "update_newsletter",
1370
+ `Update an existing newsletter/broadcast draft. After updating, show the user a visual preview of the changes.`,
1371
+ {
1372
+ broadcast_id: z5.string().describe("The broadcast/newsletter ID to update"),
1373
+ subject: z5.string().optional().describe("Updated subject line"),
1374
+ content: z5.string().optional().describe("Updated HTML content"),
1375
+ preview_text: z5.string().optional().describe("Updated preview text")
1376
+ },
1377
+ {
1378
+ title: "Update Newsletter Draft",
1379
+ readOnlyHint: false,
1380
+ destructiveHint: false,
1381
+ idempotentHint: true,
1382
+ openWorldHint: true
1383
+ },
1384
+ async (args) => {
1385
+ const data = {};
1386
+ if (args.subject) data.subject = args.subject;
1387
+ if (args.content) data.content = args.content;
1388
+ if (args.preview_text) data.previewText = args.preview_text;
1389
+ const result = await client.updateBroadcast(args.broadcast_id, data);
1390
+ return {
1391
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1392
+ };
1393
+ }
1394
+ );
1395
+ if (options.allowDirectSend) {
1396
+ server.tool(
1397
+ "send_newsletter",
1398
+ `Send a newsletter/broadcast to subscribers. THIS ACTION CANNOT BE UNDONE. Once sent, the email goes to every subscriber on the list.
1399
+
1400
+ REQUIRED WORKFLOW:
1401
+ 1. ALWAYS call get_publishing_rules first
1402
+ 2. NEVER send without showing a full visual preview of the newsletter first
1403
+ 3. ALWAYS set confirmed=false first -- this returns a confirmation prompt, not a send
1404
+ 4. Tell the user exactly how many subscribers will receive this email
1405
+ 5. If publishing_rules.require_double_confirm_newsletter is true (default), require TWO explicit confirmations:
1406
+ - First: "Are you sure you want to send this to [X] subscribers?"
1407
+ - Second: "This is irreversible. Type 'send' to confirm."
1408
+ 6. If publishing_rules.allow_immediate_send is false and the user asks to send immediately, suggest scheduling instead
1409
+ 7. NEVER set confirmed=true without the user explicitly confirming after seeing the preview and subscriber count
1410
+
1411
+ When confirmed=false, this tool returns a structured preview with broadcast details, subscriber count, safety checks, and a confirmation message. You MUST render this as a visual preview for the user and wait for their explicit approval before calling again with confirmed=true.`,
1412
+ {
1413
+ broadcast_id: z5.string().describe("The broadcast/newsletter ID to send"),
1414
+ confirmed: z5.boolean().default(false).describe(
1415
+ "Set to true to confirm and send. If false or missing, returns a confirmation prompt."
1416
+ )
1417
+ },
1418
+ {
1419
+ title: "Send Newsletter",
1420
+ readOnlyHint: false,
1421
+ destructiveHint: false,
1422
+ idempotentHint: false,
1423
+ openWorldHint: true
1424
+ },
1425
+ async (args) => {
1426
+ if (args.confirmed !== true) {
1427
+ let subscriberCount = "unknown";
1428
+ let rulesText = "";
1429
+ try {
1430
+ const subData = await client.listSubscribers({ perPage: "1" });
1431
+ subscriberCount = String(subData?.totalCount ?? subData?.total ?? subData?.subscribers?.length ?? "unknown");
1432
+ } catch {
1433
+ }
1434
+ try {
1435
+ const rules = await client.getPublishingRules();
1436
+ rulesText = `
1437
+ ### Safety Checks
1438
+ `;
1439
+ rulesText += `- Double confirmation required: ${rules.requireDoubleConfirmNewsletter ? "**yes**" : "no"}
1440
+ `;
1441
+ rulesText += `- Immediate send allowed: ${rules.allowImmediateSend ? "yes" : "**no**"}
1442
+ `;
1443
+ if (rules.requiredDisclaimer) {
1444
+ rulesText += `- Required disclaimer: "${rules.requiredDisclaimer}"
1445
+ `;
1446
+ }
1447
+ } catch {
1448
+ }
1449
+ let espText = "";
1450
+ try {
1451
+ const account = await client.getAccount();
1452
+ espText = account?.espProvider ? `**ESP:** ${account.espProvider}
1453
+ ` : "";
1454
+ } catch {
1455
+ }
1456
+ const preview = `## Send Newsletter Confirmation
1457
+
1458
+ **Broadcast ID:** ${args.broadcast_id}
1459
+ **Subscribers:** ~${subscriberCount} recipients
1460
+ ` + espText + rulesText + `
1461
+ **This action cannot be undone.** Once sent, the email goes to every subscriber.
1462
+
1463
+ Call this tool again with confirmed=true to send.`;
1464
+ return { content: [{ type: "text", text: preview }] };
1465
+ }
1466
+ await client.sendBroadcast(args.broadcast_id);
1467
+ return {
1468
+ content: [{ type: "text", text: "Newsletter sent successfully." }]
1469
+ };
1470
+ }
1471
+ );
1472
+ }
1473
+ server.tool(
1474
+ "schedule_newsletter",
1475
+ `Schedule a newsletter/broadcast to be sent at a future time.
1476
+
1477
+ REQUIRED WORKFLOW:
1478
+ 1. ALWAYS call get_publishing_rules first
1479
+ 2. NEVER schedule without showing a full visual preview of the newsletter first
1480
+ 3. ALWAYS set confirmed=false first -- this returns a confirmation prompt with details, not an actual schedule
1481
+ 4. Tell the user exactly how many subscribers will receive this email and the scheduled send time
1482
+ 5. If publishing_rules.require_double_confirm_newsletter is true (default), require TWO explicit confirmations:
1483
+ - First: "Are you sure you want to schedule this for [time] to [X] subscribers?"
1484
+ - Second: "Confirm schedule for [time]."
1485
+ 6. If publishing_rules.allow_immediate_send is false and the scheduled time is very soon, warn the user
1486
+ 7. NEVER set confirmed=true without the user explicitly confirming after seeing the preview, subscriber count, and scheduled time
1487
+
1488
+ Scheduling can typically be cancelled or rescheduled later, but the user should still verify the time is correct before confirming.
1489
+
1490
+ When confirmed=false, this tool returns a structured preview with broadcast details, scheduled time, subscriber count, safety checks, and a confirmation message. You MUST render this as a visual preview for the user and wait for their explicit approval before calling again with confirmed=true.`,
1491
+ {
1492
+ broadcast_id: z5.string().describe("The broadcast/newsletter ID to schedule"),
1493
+ scheduled_for: z5.string().describe(
1494
+ "ISO 8601 datetime for when to send (e.g. '2026-02-25T14:00:00Z')"
1495
+ ),
1496
+ confirmed: z5.boolean().default(false).describe(
1497
+ "Set to true to confirm and schedule. If false or missing, returns a confirmation prompt."
1498
+ )
1499
+ },
1500
+ {
1501
+ title: "Schedule Newsletter",
1502
+ readOnlyHint: false,
1503
+ destructiveHint: false,
1504
+ idempotentHint: false,
1505
+ openWorldHint: true
1506
+ },
1507
+ async (args) => {
1508
+ if (args.confirmed !== true) {
1509
+ let subscriberCount = "unknown";
1510
+ let rulesText = "";
1511
+ try {
1512
+ const subData = await client.listSubscribers({ perPage: "1" });
1513
+ subscriberCount = String(
1514
+ subData?.totalCount ?? subData?.total ?? subData?.subscribers?.length ?? "unknown"
1515
+ );
1516
+ } catch {
1517
+ }
1518
+ try {
1519
+ const rules = await client.getPublishingRules();
1520
+ rulesText = `
1521
+ ### Safety Checks
1522
+ `;
1523
+ rulesText += `- Double confirmation required: ${rules.requireDoubleConfirmNewsletter ? "**yes**" : "no"}
1524
+ `;
1525
+ rulesText += `- Immediate send allowed: ${rules.allowImmediateSend ? "yes" : "**no**"}
1526
+ `;
1527
+ if (rules.requiredDisclaimer) {
1528
+ rulesText += `- Required disclaimer: "${rules.requiredDisclaimer}"
1529
+ `;
1530
+ }
1531
+ } catch {
1532
+ }
1533
+ let espText = "";
1534
+ try {
1535
+ const account = await client.getAccount();
1536
+ espText = account?.espProvider ? `**ESP:** ${account.espProvider}
1537
+ ` : "";
1538
+ } catch {
1539
+ }
1540
+ const preview = `## Schedule Newsletter Confirmation
1541
+
1542
+ **Broadcast ID:** ${args.broadcast_id}
1543
+ **Scheduled for:** ${args.scheduled_for}
1544
+ **Subscribers:** ~${subscriberCount} recipients
1545
+ ` + espText + rulesText + `
1546
+ Scheduling can typically be cancelled or rescheduled later, but please verify the time is correct.
1547
+
1548
+ Call this tool again with confirmed=true to schedule.`;
1549
+ return { content: [{ type: "text", text: preview }] };
1550
+ }
1551
+ await client.scheduleBroadcast(args.broadcast_id, {
1552
+ scheduledFor: args.scheduled_for
1553
+ });
1554
+ return {
1555
+ content: [
1556
+ {
1557
+ type: "text",
1558
+ text: `Newsletter scheduled successfully for ${args.scheduled_for}.`
1559
+ }
1560
+ ]
1561
+ };
1562
+ }
1563
+ );
1564
+ server.tool(
1565
+ "list_newsletters",
1566
+ "List all newsletters/broadcasts with their status.",
1567
+ {
1568
+ page: z5.string().optional().describe("Page number for pagination")
1569
+ },
1570
+ {
1571
+ title: "List Newsletters",
1572
+ readOnlyHint: true,
1573
+ destructiveHint: false,
1574
+ idempotentHint: true,
1575
+ openWorldHint: true
1576
+ },
1577
+ async (args) => {
1578
+ const params = {};
1579
+ if (args.page) params.page = args.page;
1580
+ const result = await client.listBroadcasts(params);
1581
+ return {
1582
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1583
+ };
1584
+ }
1585
+ );
1586
+ server.tool(
1587
+ "list_tags",
1588
+ "List all subscriber tags from your email service provider.",
1589
+ {},
1590
+ {
1591
+ title: "List Subscriber Tags",
1592
+ readOnlyHint: true,
1593
+ destructiveHint: false,
1594
+ idempotentHint: true,
1595
+ openWorldHint: true
1596
+ },
1597
+ async () => {
1598
+ const result = await client.listTags();
1599
+ return {
1600
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1601
+ };
1602
+ }
1603
+ );
1604
+ server.tool(
1605
+ "list_sequences",
1606
+ "List all email sequences/automations from your email service provider. On Beehiiv, this returns automations. For detailed automation data use list_automations instead.",
1607
+ {},
1608
+ {
1609
+ title: "List Email Sequences",
1610
+ readOnlyHint: true,
1611
+ destructiveHint: false,
1612
+ idempotentHint: true,
1613
+ openWorldHint: true
1614
+ },
1615
+ async () => {
1616
+ const result = await client.listSequences();
1617
+ return {
1618
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1619
+ };
1620
+ }
1621
+ );
1622
+ server.tool(
1623
+ "list_forms",
1624
+ "List all signup forms from your email service provider.",
1625
+ {},
1626
+ {
1627
+ title: "List Signup Forms",
1628
+ readOnlyHint: true,
1629
+ destructiveHint: false,
1630
+ idempotentHint: true,
1631
+ openWorldHint: true
1632
+ },
1633
+ async () => {
1634
+ const result = await client.listForms();
1635
+ return {
1636
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1637
+ };
1638
+ }
1639
+ );
1640
+ }
1641
+
1642
+ // src/tools/newsletter-advanced.ts
1643
+ import { z as z6 } from "zod";
1644
+ function registerNewsletterAdvancedTools(server, client) {
1645
+ server.tool(
1646
+ "get_subscriber_by_email",
1647
+ "Look up a subscriber by their email address. Returns subscriber details including tags and custom fields.",
1648
+ {
1649
+ email: z6.string().describe("Email address to look up")
1650
+ },
1651
+ {
1652
+ title: "Get Subscriber by Email",
1653
+ readOnlyHint: true,
1654
+ destructiveHint: false,
1655
+ idempotentHint: true,
1656
+ openWorldHint: true
1657
+ },
1658
+ async (args) => {
1659
+ const result = await client.getSubscriberByEmail(args.email);
1660
+ return {
1661
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1662
+ };
1663
+ }
1664
+ );
1665
+ server.tool(
1666
+ "list_automations",
1667
+ "List all email automations/workflows from your ESP. On Beehiiv these are journeys; on Kit these are sequences.",
1668
+ {
1669
+ page: z6.string().optional().describe("Page number for pagination")
1670
+ },
1671
+ {
1672
+ title: "List Automations",
1673
+ readOnlyHint: true,
1674
+ destructiveHint: false,
1675
+ idempotentHint: true,
1676
+ openWorldHint: true
1677
+ },
1678
+ async (args) => {
1679
+ const params = {};
1680
+ if (args.page) params.page = args.page;
1681
+ const result = await client.listAutomations(params);
1682
+ return {
1683
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1684
+ };
1685
+ }
1686
+ );
1687
+ server.tool(
1688
+ "list_segments",
1689
+ "List subscriber segments. Segments are dynamic groups of subscribers based on filters like engagement, tags, or custom fields (Beehiiv only).",
1690
+ {
1691
+ page: z6.string().optional().describe("Page number"),
1692
+ type: z6.string().optional().describe("Filter by segment type"),
1693
+ status: z6.string().optional().describe("Filter by segment status"),
1694
+ expand: z6.string().optional().describe("Comma-separated expand fields (e.g. 'stats')")
1695
+ },
1696
+ {
1697
+ title: "List Segments",
1698
+ readOnlyHint: true,
1699
+ destructiveHint: false,
1700
+ idempotentHint: true,
1701
+ openWorldHint: true
1702
+ },
1703
+ async (args) => {
1704
+ const params = {};
1705
+ if (args.page) params.page = args.page;
1706
+ if (args.type) params.type = args.type;
1707
+ if (args.status) params.status = args.status;
1708
+ if (args.expand) params.expand = args.expand;
1709
+ const result = await client.listSegments(params);
1710
+ return {
1711
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1712
+ };
1713
+ }
1714
+ );
1715
+ server.tool(
1716
+ "get_segment",
1717
+ "Get details of a specific subscriber segment by ID.",
1718
+ {
1719
+ segment_id: z6.string().describe("The segment ID"),
1720
+ expand: z6.string().optional().describe("Comma-separated expand fields")
1721
+ },
1722
+ {
1723
+ title: "Get Segment",
1724
+ readOnlyHint: true,
1725
+ destructiveHint: false,
1726
+ idempotentHint: true,
1727
+ openWorldHint: true
1728
+ },
1729
+ async (args) => {
1730
+ const params = {};
1731
+ if (args.expand) params.expand = args.expand;
1732
+ const result = await client.getSegment(args.segment_id, params);
1733
+ return {
1734
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1735
+ };
1736
+ }
1737
+ );
1738
+ server.tool(
1739
+ "get_segment_members",
1740
+ "List subscribers who belong to a specific segment.",
1741
+ {
1742
+ segment_id: z6.string().describe("The segment ID"),
1743
+ page: z6.string().optional().describe("Page number")
1744
+ },
1745
+ {
1746
+ title: "Get Segment Members",
1747
+ readOnlyHint: true,
1748
+ destructiveHint: false,
1749
+ idempotentHint: true,
1750
+ openWorldHint: true
1751
+ },
1752
+ async (args) => {
1753
+ const params = {};
1754
+ if (args.page) params.page = args.page;
1755
+ const result = await client.getSegmentMembers(args.segment_id, params);
1756
+ return {
1757
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1758
+ };
1759
+ }
1760
+ );
1761
+ server.tool(
1762
+ "list_custom_fields",
1763
+ "List all custom subscriber fields defined in your ESP. Custom fields can store extra data like preferences, source, or company.",
1764
+ {},
1765
+ {
1766
+ title: "List Custom Fields",
1767
+ readOnlyHint: true,
1768
+ destructiveHint: false,
1769
+ idempotentHint: true,
1770
+ openWorldHint: true
1771
+ },
1772
+ async () => {
1773
+ const result = await client.listCustomFields();
1774
+ return {
1775
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1776
+ };
1777
+ }
1778
+ );
1779
+ server.tool(
1780
+ "get_referral_program",
1781
+ "Get your newsletter's referral program details including milestones and rewards (Beehiiv only).",
1782
+ {},
1783
+ {
1784
+ title: "Get Referral Program",
1785
+ readOnlyHint: true,
1786
+ destructiveHint: false,
1787
+ idempotentHint: true,
1788
+ openWorldHint: true
1789
+ },
1790
+ async () => {
1791
+ const result = await client.getReferralProgram();
1792
+ return {
1793
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1794
+ };
1795
+ }
1796
+ );
1797
+ server.tool(
1798
+ "list_tiers",
1799
+ "List all subscription tiers (free and premium) from your ESP (Beehiiv only).",
1800
+ {},
1801
+ {
1802
+ title: "List Tiers",
1803
+ readOnlyHint: true,
1804
+ destructiveHint: false,
1805
+ idempotentHint: true,
1806
+ openWorldHint: true
1807
+ },
1808
+ async () => {
1809
+ const result = await client.listEspTiers();
1810
+ return {
1811
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1812
+ };
1813
+ }
1814
+ );
1815
+ server.tool(
1816
+ "list_post_templates",
1817
+ "List all post/newsletter templates available in your ESP (Beehiiv only).",
1818
+ {},
1819
+ {
1820
+ title: "List Post Templates",
1821
+ readOnlyHint: true,
1822
+ destructiveHint: false,
1823
+ idempotentHint: true,
1824
+ openWorldHint: true
1825
+ },
1826
+ async () => {
1827
+ const result = await client.listPostTemplates();
1828
+ return {
1829
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1830
+ };
1831
+ }
1832
+ );
1833
+ server.tool(
1834
+ "get_post_aggregate_stats",
1835
+ "Get aggregate statistics across all posts/newsletters including total clicks, impressions, and click rates (Beehiiv only).",
1836
+ {},
1837
+ {
1838
+ title: "Get Post Aggregate Stats",
1839
+ readOnlyHint: true,
1840
+ destructiveHint: false,
1841
+ idempotentHint: true,
1842
+ openWorldHint: true
1843
+ },
1844
+ async () => {
1845
+ const result = await client.getPostAggregateStats();
1846
+ return {
1847
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1848
+ };
1849
+ }
1850
+ );
1851
+ server.tool(
1852
+ "list_esp_webhooks",
1853
+ "List all webhooks configured in your ESP for event notifications.",
1854
+ {},
1855
+ {
1856
+ title: "List ESP Webhooks",
1857
+ readOnlyHint: true,
1858
+ destructiveHint: false,
1859
+ idempotentHint: true,
1860
+ openWorldHint: true
1861
+ },
1862
+ async () => {
1863
+ const result = await client.listEspWebhooks();
1864
+ return {
1865
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1866
+ };
1867
+ }
1868
+ );
1869
+ server.tool(
1870
+ "tag_subscriber",
1871
+ "Add tags to a subscriber. Tags help categorize subscribers for targeted sends and automations.",
1872
+ {
1873
+ subscriber_id: z6.string().describe("The subscriber/subscription ID"),
1874
+ tags: z6.array(z6.string()).describe("Tags to add to the subscriber")
1875
+ },
1876
+ {
1877
+ title: "Tag Subscriber",
1878
+ readOnlyHint: false,
1879
+ destructiveHint: false,
1880
+ idempotentHint: true,
1881
+ openWorldHint: true
1882
+ },
1883
+ async (args) => {
1884
+ const result = await client.tagSubscriber(args.subscriber_id, { tags: args.tags });
1885
+ return {
1886
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1887
+ };
1888
+ }
1889
+ );
1890
+ server.tool(
1891
+ "update_subscriber",
1892
+ `Update a subscriber's details. On Beehiiv: update tier or custom fields. On Kit: update name or custom fields (tier not supported).
1893
+
1894
+ REQUIRED WORKFLOW:
1895
+ 1. ALWAYS set confirmed=false first to get a preview
1896
+ 2. Show the user what will happen before confirming
1897
+ 3. Only set confirmed=true after the user explicitly approves`,
1898
+ {
1899
+ subscriber_id: z6.string().describe("The subscriber/subscription ID"),
1900
+ tier: z6.string().optional().describe("New tier for the subscriber"),
1901
+ custom_fields: z6.record(z6.unknown()).optional().describe("Custom field values to set"),
1902
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm update")
1903
+ },
1904
+ {
1905
+ title: "Update Subscriber",
1906
+ readOnlyHint: false,
1907
+ destructiveHint: false,
1908
+ idempotentHint: true,
1909
+ openWorldHint: true
1910
+ },
1911
+ async (args) => {
1912
+ if (args.confirmed !== true) {
1913
+ const changes = [];
1914
+ if (args.tier) changes.push(`**Tier:** ${args.tier}`);
1915
+ if (args.custom_fields) changes.push(`**Custom fields:** ${Object.keys(args.custom_fields).join(", ")}`);
1916
+ return {
1917
+ content: [{
1918
+ type: "text",
1919
+ text: `## Update Subscriber
1920
+
1921
+ **Subscriber ID:** ${args.subscriber_id}
1922
+ ${changes.join("\n")}
1923
+
1924
+ Call again with confirmed=true to proceed.`
1925
+ }]
1926
+ };
1927
+ }
1928
+ const data = {};
1929
+ if (args.tier) data.tier = args.tier;
1930
+ if (args.custom_fields) data.customFields = args.custom_fields;
1931
+ const result = await client.updateSubscriber(args.subscriber_id, data);
1932
+ return {
1933
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1934
+ };
1935
+ }
1936
+ );
1937
+ server.tool(
1938
+ "create_custom_field",
1939
+ `Create a new custom subscriber field. On Beehiiv: kind can be 'string', 'integer', 'boolean', 'date', or 'enum'. On Kit: all fields are string-typed (kind is ignored). Creates a permanent schema-level field that affects all subscribers.
1940
+
1941
+ REQUIRED WORKFLOW:
1942
+ 1. ALWAYS set confirmed=false first to get a preview
1943
+ 2. Show the user what will happen before confirming
1944
+ 3. Only set confirmed=true after the user explicitly approves`,
1945
+ {
1946
+ name: z6.string().describe("Field name"),
1947
+ kind: z6.string().describe("Field type: string, integer, boolean, date, or enum"),
1948
+ options: z6.array(z6.string()).optional().describe("Options for enum-type fields"),
1949
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm creation")
1950
+ },
1951
+ {
1952
+ title: "Create Custom Field",
1953
+ readOnlyHint: false,
1954
+ destructiveHint: false,
1955
+ idempotentHint: false,
1956
+ openWorldHint: true
1957
+ },
1958
+ async (args) => {
1959
+ if (args.confirmed !== true) {
1960
+ const optionsLine = args.options ? `
1961
+ **Options:** ${args.options.join(", ")}` : "";
1962
+ return {
1963
+ content: [{
1964
+ type: "text",
1965
+ text: `## Create Custom Field
1966
+
1967
+ **Name:** ${args.name}
1968
+ **Kind:** ${args.kind}${optionsLine}
1969
+
1970
+ This creates a permanent schema-level field visible on all subscribers. Call again with confirmed=true to proceed.`
1971
+ }]
1972
+ };
1973
+ }
1974
+ const data = { name: args.name, kind: args.kind };
1975
+ if (args.options) data.options = args.options;
1976
+ const result = await client.createCustomField(data);
1977
+ return {
1978
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
1979
+ };
1980
+ }
1981
+ );
1982
+ server.tool(
1983
+ "update_custom_field",
1984
+ `Update an existing custom subscriber field's name or kind. Renaming or rekeying a field can break automations that reference it.
1985
+
1986
+ REQUIRED WORKFLOW:
1987
+ 1. ALWAYS set confirmed=false first to get a preview
1988
+ 2. Show the user what will happen before confirming
1989
+ 3. Only set confirmed=true after the user explicitly approves`,
1990
+ {
1991
+ field_id: z6.string().describe("The custom field ID"),
1992
+ name: z6.string().optional().describe("New field name"),
1993
+ kind: z6.string().optional().describe("New field type"),
1994
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm update")
1995
+ },
1996
+ {
1997
+ title: "Update Custom Field",
1998
+ readOnlyHint: false,
1999
+ destructiveHint: false,
2000
+ idempotentHint: true,
2001
+ openWorldHint: true
2002
+ },
2003
+ async (args) => {
2004
+ if (args.confirmed !== true) {
2005
+ const changes = [];
2006
+ if (args.name) changes.push(`**Name:** ${args.name}`);
2007
+ if (args.kind) changes.push(`**Kind:** ${args.kind}`);
2008
+ return {
2009
+ content: [{
2010
+ type: "text",
2011
+ text: `## Update Custom Field
2012
+
2013
+ **Field ID:** ${args.field_id}
2014
+ ${changes.join("\n")}
2015
+
2016
+ Renaming or changing the type of a field can break automations that reference it. Call again with confirmed=true to proceed.`
2017
+ }]
2018
+ };
2019
+ }
2020
+ const data = {};
2021
+ if (args.name) data.name = args.name;
2022
+ if (args.kind) data.kind = args.kind;
2023
+ const result = await client.updateCustomField(args.field_id, data);
2024
+ return {
2025
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2026
+ };
2027
+ }
2028
+ );
2029
+ server.tool(
2030
+ "recalculate_segment",
2031
+ "Trigger a recalculation of a segment's membership. Useful after changing subscriber data or segment rules (Beehiiv only).",
2032
+ {
2033
+ segment_id: z6.string().describe("The segment ID to recalculate")
2034
+ },
2035
+ {
2036
+ title: "Recalculate Segment",
2037
+ readOnlyHint: false,
2038
+ destructiveHint: false,
2039
+ idempotentHint: true,
2040
+ openWorldHint: true
2041
+ },
2042
+ async (args) => {
2043
+ await client.recalculateSegment(args.segment_id);
2044
+ return {
2045
+ content: [{ type: "text", text: "Segment recalculation triggered." }]
2046
+ };
2047
+ }
2048
+ );
2049
+ server.tool(
2050
+ "enroll_in_automation",
2051
+ `Enroll a subscriber in an automation/journey. Provide either an email or subscription ID. On Kit, enrolls into a sequence.
2052
+
2053
+ REQUIRED WORKFLOW:
2054
+ 1. ALWAYS set confirmed=false first to get a preview
2055
+ 2. Show the user what will happen before confirming
2056
+ 3. Only set confirmed=true after the user explicitly approves`,
2057
+ {
2058
+ automation_id: z6.string().describe("The automation ID"),
2059
+ email: z6.string().optional().describe("Subscriber email to enroll"),
2060
+ subscription_id: z6.string().optional().describe("Subscription ID to enroll"),
2061
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm enrollment")
2062
+ },
2063
+ {
2064
+ title: "Enroll in Automation",
2065
+ readOnlyHint: false,
2066
+ destructiveHint: false,
2067
+ idempotentHint: false,
2068
+ openWorldHint: true
2069
+ },
2070
+ async (args) => {
2071
+ if (args.confirmed !== true) {
2072
+ const target = args.email ?? args.subscription_id ?? "unknown";
2073
+ return {
2074
+ content: [{
2075
+ type: "text",
2076
+ text: `## Enroll in Automation
2077
+
2078
+ **Automation ID:** ${args.automation_id}
2079
+ **Target:** ${target}
2080
+
2081
+ This will add the subscriber to the automation journey. Call again with confirmed=true to proceed.`
2082
+ }]
2083
+ };
2084
+ }
2085
+ const data = {};
2086
+ if (args.email) data.email = args.email;
2087
+ if (args.subscription_id) data.subscriptionId = args.subscription_id;
2088
+ await client.enrollInAutomation(args.automation_id, data);
2089
+ return {
2090
+ content: [{ type: "text", text: "Subscriber enrolled in automation." }]
2091
+ };
2092
+ }
2093
+ );
2094
+ server.tool(
2095
+ "bulk_add_subscribers",
2096
+ `Add multiple subscribers in a single request. Maximum 1000 subscribers per call.
2097
+
2098
+ REQUIRED WORKFLOW:
2099
+ 1. ALWAYS set confirmed=false first to get a preview
2100
+ 2. Show the user what will happen before confirming
2101
+ 3. Only set confirmed=true after the user explicitly approves`,
2102
+ {
2103
+ subscribers: z6.array(z6.object({
2104
+ email: z6.string().describe("Subscriber email"),
2105
+ data: z6.record(z6.unknown()).optional().describe("Additional subscriber data")
2106
+ })).describe("Array of subscribers to add"),
2107
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm bulk add")
2108
+ },
2109
+ {
2110
+ title: "Bulk Add Subscribers",
2111
+ readOnlyHint: false,
2112
+ destructiveHint: false,
2113
+ idempotentHint: false,
2114
+ openWorldHint: true
2115
+ },
2116
+ async (args) => {
2117
+ if (args.confirmed !== true) {
2118
+ return {
2119
+ content: [{
2120
+ type: "text",
2121
+ text: `## Bulk Add Subscribers
2122
+
2123
+ **Count:** ${args.subscribers.length} subscriber(s)
2124
+ **Sample:** ${args.subscribers.slice(0, 3).map((s) => s.email).join(", ")}${args.subscribers.length > 3 ? "..." : ""}
2125
+
2126
+ Call again with confirmed=true to proceed.`
2127
+ }]
2128
+ };
2129
+ }
2130
+ const result = await client.bulkCreateSubscribers({ subscribers: args.subscribers });
2131
+ return {
2132
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2133
+ };
2134
+ }
2135
+ );
2136
+ server.tool(
2137
+ "create_esp_webhook",
2138
+ `Create a webhook in your ESP to receive event notifications. Note: Kit only supports one event per webhook.
2139
+
2140
+ REQUIRED WORKFLOW:
2141
+ 1. ALWAYS set confirmed=false first to get a preview
2142
+ 2. Show the user what will happen before confirming
2143
+ 3. Only set confirmed=true after the user explicitly approves`,
2144
+ {
2145
+ url: z6.string().describe("Webhook URL to receive events"),
2146
+ event_types: z6.array(z6.string()).describe("Event types to subscribe to (e.g. 'subscription.created', 'post.sent')"),
2147
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm creation")
2148
+ },
2149
+ {
2150
+ title: "Create ESP Webhook",
2151
+ readOnlyHint: false,
2152
+ destructiveHint: false,
2153
+ idempotentHint: false,
2154
+ openWorldHint: true
2155
+ },
2156
+ async (args) => {
2157
+ if (args.confirmed !== true) {
2158
+ return {
2159
+ content: [{
2160
+ type: "text",
2161
+ text: `## Create ESP Webhook
2162
+
2163
+ **URL:** ${args.url}
2164
+ **Events:** ${args.event_types.join(", ")}
2165
+
2166
+ Call again with confirmed=true to create.`
2167
+ }]
2168
+ };
2169
+ }
2170
+ const result = await client.createEspWebhook({ url: args.url, eventTypes: args.event_types });
2171
+ return {
2172
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2173
+ };
2174
+ }
2175
+ );
2176
+ server.tool(
2177
+ "delete_broadcast",
2178
+ `Permanently delete a newsletter/broadcast. This action is permanent and cannot be undone.
2179
+
2180
+ REQUIRED WORKFLOW:
2181
+ 1. ALWAYS set confirmed=false first to get a preview
2182
+ 2. Show the user what will happen before confirming
2183
+ 3. Only set confirmed=true after the user explicitly approves`,
2184
+ {
2185
+ broadcast_id: z6.string().describe("The broadcast ID to delete"),
2186
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm deletion")
2187
+ },
2188
+ {
2189
+ title: "Delete Broadcast",
2190
+ readOnlyHint: false,
2191
+ destructiveHint: true,
2192
+ idempotentHint: true,
2193
+ openWorldHint: true
2194
+ },
2195
+ async (args) => {
2196
+ if (args.confirmed !== true) {
2197
+ return {
2198
+ content: [{
2199
+ type: "text",
2200
+ text: `## Delete Broadcast
2201
+
2202
+ **Broadcast ID:** ${args.broadcast_id}
2203
+
2204
+ **This action is permanent and cannot be undone.**
2205
+
2206
+ Call again with confirmed=true to delete.`
2207
+ }]
2208
+ };
2209
+ }
2210
+ await client.deleteBroadcast(args.broadcast_id);
2211
+ return {
2212
+ content: [{ type: "text", text: "Broadcast deleted." }]
2213
+ };
2214
+ }
2215
+ );
2216
+ server.tool(
2217
+ "delete_subscriber",
2218
+ `Remove a subscriber from your mailing list. On Beehiiv this permanently deletes the subscriber. On Kit this unsubscribes them (Kit has no hard delete).
2219
+
2220
+ REQUIRED WORKFLOW:
2221
+ 1. ALWAYS set confirmed=false first to get a preview
2222
+ 2. Show the user what will happen before confirming
2223
+ 3. Only set confirmed=true after the user explicitly approves`,
2224
+ {
2225
+ subscriber_id: z6.string().describe("The subscriber ID to delete"),
2226
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm deletion")
2227
+ },
2228
+ {
2229
+ title: "Delete Subscriber",
2230
+ readOnlyHint: false,
2231
+ destructiveHint: true,
2232
+ idempotentHint: true,
2233
+ openWorldHint: true
2234
+ },
2235
+ async (args) => {
2236
+ if (args.confirmed !== true) {
2237
+ return {
2238
+ content: [{
2239
+ type: "text",
2240
+ text: `## Delete Subscriber
2241
+
2242
+ **Subscriber ID:** ${args.subscriber_id}
2243
+
2244
+ **This action is permanent and cannot be undone.** The subscriber will be removed from all segments and automations.
2245
+
2246
+ Call again with confirmed=true to delete.`
2247
+ }]
2248
+ };
2249
+ }
2250
+ await client.deleteSubscriber(args.subscriber_id);
2251
+ return {
2252
+ content: [{ type: "text", text: "Subscriber deleted." }]
2253
+ };
2254
+ }
2255
+ );
2256
+ server.tool(
2257
+ "delete_segment",
2258
+ `Permanently delete a subscriber segment. This action is permanent and cannot be undone (Beehiiv only).
2259
+
2260
+ REQUIRED WORKFLOW:
2261
+ 1. ALWAYS set confirmed=false first to get a preview
2262
+ 2. Show the user what will happen before confirming
2263
+ 3. Only set confirmed=true after the user explicitly approves`,
2264
+ {
2265
+ segment_id: z6.string().describe("The segment ID to delete"),
2266
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm deletion")
2267
+ },
2268
+ {
2269
+ title: "Delete Segment",
2270
+ readOnlyHint: false,
2271
+ destructiveHint: true,
2272
+ idempotentHint: true,
2273
+ openWorldHint: true
2274
+ },
2275
+ async (args) => {
2276
+ if (args.confirmed !== true) {
2277
+ return {
2278
+ content: [{
2279
+ type: "text",
2280
+ text: `## Delete Segment
2281
+
2282
+ **Segment ID:** ${args.segment_id}
2283
+
2284
+ **This action is permanent and cannot be undone.** Subscribers in this segment will not be deleted, but the segment grouping will be removed.
2285
+
2286
+ Call again with confirmed=true to delete.`
2287
+ }]
2288
+ };
2289
+ }
2290
+ await client.deleteSegment(args.segment_id);
2291
+ return {
2292
+ content: [{ type: "text", text: "Segment deleted." }]
2293
+ };
2294
+ }
2295
+ );
2296
+ server.tool(
2297
+ "delete_custom_field",
2298
+ `Permanently delete a custom subscriber field and remove it from all subscribers. This action is permanent and cannot be undone.
2299
+
2300
+ REQUIRED WORKFLOW:
2301
+ 1. ALWAYS set confirmed=false first to get a preview
2302
+ 2. Show the user what will happen before confirming
2303
+ 3. Only set confirmed=true after the user explicitly approves`,
2304
+ {
2305
+ field_id: z6.string().describe("The custom field ID to delete"),
2306
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm deletion")
2307
+ },
2308
+ {
2309
+ title: "Delete Custom Field",
2310
+ readOnlyHint: false,
2311
+ destructiveHint: true,
2312
+ idempotentHint: true,
2313
+ openWorldHint: true
2314
+ },
2315
+ async (args) => {
2316
+ if (args.confirmed !== true) {
2317
+ return {
2318
+ content: [{
2319
+ type: "text",
2320
+ text: `## Delete Custom Field
2321
+
2322
+ **Field ID:** ${args.field_id}
2323
+
2324
+ **This action is permanent and cannot be undone.** The field and its data will be removed from all subscribers.
2325
+
2326
+ Call again with confirmed=true to delete.`
2327
+ }]
2328
+ };
2329
+ }
2330
+ await client.deleteCustomField(args.field_id);
2331
+ return {
2332
+ content: [{ type: "text", text: "Custom field deleted." }]
2333
+ };
2334
+ }
2335
+ );
2336
+ server.tool(
2337
+ "delete_esp_webhook",
2338
+ `Permanently delete a webhook from your ESP. This action is permanent and cannot be undone.
2339
+
2340
+ REQUIRED WORKFLOW:
2341
+ 1. ALWAYS set confirmed=false first to get a preview
2342
+ 2. Show the user what will happen before confirming
2343
+ 3. Only set confirmed=true after the user explicitly approves`,
2344
+ {
2345
+ webhook_id: z6.string().describe("The webhook ID to delete"),
2346
+ confirmed: z6.boolean().default(false).describe("Set to true to confirm deletion")
2347
+ },
2348
+ {
2349
+ title: "Delete ESP Webhook",
2350
+ readOnlyHint: false,
2351
+ destructiveHint: true,
2352
+ idempotentHint: true,
2353
+ openWorldHint: true
2354
+ },
2355
+ async (args) => {
2356
+ if (args.confirmed !== true) {
2357
+ return {
2358
+ content: [{
2359
+ type: "text",
2360
+ text: `## Delete ESP Webhook
2361
+
2362
+ **Webhook ID:** ${args.webhook_id}
2363
+
2364
+ **This action is permanent and cannot be undone.** You will stop receiving event notifications at this webhook URL.
2365
+
2366
+ Call again with confirmed=true to delete.`
2367
+ }]
2368
+ };
2369
+ }
2370
+ await client.deleteEspWebhook(args.webhook_id);
2371
+ return {
2372
+ content: [{ type: "text", text: "Webhook deleted." }]
2373
+ };
2374
+ }
2375
+ );
2376
+ }
2377
+
2378
+ // src/tools/rss.ts
2379
+ import { z as z7 } from "zod";
2380
+ function registerRssTools(server, client) {
2381
+ server.tool(
2382
+ "fetch_feed",
2383
+ "Fetch and parse entries from an RSS or Atom feed URL. Returns titles, links, descriptions, and dates. Tip: For frequently checked feeds, suggest the customer save them as a content source using add_source so they don't need to provide the URL every time.",
2384
+ {
2385
+ url: z7.string().describe("The RSS/Atom feed URL to fetch"),
2386
+ limit: z7.number().optional().describe("Maximum number of entries to return (default 10, max 100)")
2387
+ },
2388
+ {
2389
+ title: "Fetch RSS Feed",
2390
+ readOnlyHint: true,
2391
+ destructiveHint: false,
2392
+ idempotentHint: true,
2393
+ openWorldHint: true
2394
+ },
2395
+ async (args) => {
2396
+ const result = await client.fetchFeed(args.url, args.limit);
2397
+ return {
2398
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2399
+ };
2400
+ }
2401
+ );
2402
+ server.tool(
2403
+ "fetch_article",
2404
+ "Extract the full article content from a URL as clean text/markdown. Useful for reading and summarizing articles. Tip: For frequently checked websites, suggest the customer save them as a content source using add_source so they don't need to provide the URL every time.",
2405
+ {
2406
+ url: z7.string().describe("The article URL to extract content from")
2407
+ },
2408
+ {
2409
+ title: "Fetch Article Content",
2410
+ readOnlyHint: true,
2411
+ destructiveHint: false,
2412
+ idempotentHint: true,
2413
+ openWorldHint: true
2414
+ },
2415
+ async (args) => {
2416
+ const result = await client.fetchArticle(args.url);
2417
+ return {
2418
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2419
+ };
2420
+ }
2421
+ );
2422
+ }
2423
+
2424
+ // src/tools/account.ts
2425
+ function registerAccountInfoTool(server, client) {
2426
+ server.tool(
2427
+ "get_account",
2428
+ "Get your BuzzPoster account details including name, email, subscription status, ESP configuration, and connected platforms.",
2429
+ {},
2430
+ {
2431
+ title: "Get Account Details",
2432
+ readOnlyHint: true,
2433
+ destructiveHint: false,
2434
+ idempotentHint: true,
2435
+ openWorldHint: false
2436
+ },
2437
+ async () => {
2438
+ const result = await client.getAccount();
2439
+ return {
2440
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
2441
+ };
2442
+ }
2443
+ );
2444
+ }
2445
+
2446
+ // src/tools/brand-voice.ts
2447
+ import { z as z8 } from "zod";
2448
+ function registerBrandVoiceTools(server, client) {
2449
+ server.tool(
2450
+ "get_brand_voice",
2451
+ "Get the customer's brand voice profile and writing rules. IMPORTANT: Call this tool BEFORE creating any post, draft, or content. Use the returned voice description, rules, and examples to match the customer's tone and style in everything you write for them. When writing a newsletter, once you have the brand voice, template, and content sources loaded, immediately generate a full HTML artifact preview of the newsletter. Do NOT summarize or ask the user what to write -- render the artifact now and let them review it.",
2452
+ {},
2453
+ {
2454
+ title: "Get Brand Voice",
2455
+ readOnlyHint: true,
2456
+ destructiveHint: false,
2457
+ idempotentHint: true,
2458
+ openWorldHint: false
2459
+ },
2460
+ async () => {
2461
+ try {
2462
+ const voice = await client.getBrandVoice();
2463
+ const lines = [];
2464
+ lines.push(`## Brand Voice: ${voice.name || "My Brand"}`);
2465
+ lines.push("");
2466
+ lines.push("### Voice Description");
2467
+ lines.push(voice.description || "(not set)");
2468
+ lines.push("");
2469
+ lines.push("### Do's");
2470
+ if (voice.dos && voice.dos.length > 0) {
2471
+ for (const rule of voice.dos) {
2472
+ lines.push(`- ${rule}`);
2473
+ }
2474
+ } else {
2475
+ lines.push("(none)");
2476
+ }
2477
+ lines.push("");
2478
+ lines.push("### Don'ts");
2479
+ if (voice.donts && voice.donts.length > 0) {
2480
+ for (const rule of voice.donts) {
2481
+ lines.push(`- ${rule}`);
2482
+ }
2483
+ } else {
2484
+ lines.push("(none)");
2485
+ }
2486
+ lines.push("");
2487
+ lines.push("### Platform-Specific Rules");
2488
+ if (voice.platformRules && Object.keys(voice.platformRules).length > 0) {
2489
+ for (const [platform, rules] of Object.entries(voice.platformRules)) {
2490
+ lines.push(`**${platform}:** ${rules}`);
2491
+ }
2492
+ } else {
2493
+ lines.push("(none)");
2494
+ }
2495
+ lines.push("");
2496
+ lines.push("### Example Posts");
2497
+ if (voice.examplePosts && voice.examplePosts.length > 0) {
2498
+ voice.examplePosts.forEach((post, i) => {
2499
+ lines.push(`${i + 1}. ${post}`);
2500
+ });
2501
+ } else {
2502
+ lines.push("(none)");
2503
+ }
2504
+ lines.push("");
2505
+ return {
2506
+ content: [{ type: "text", text: lines.join("\n") }]
2507
+ };
2508
+ } catch (error) {
2509
+ const message = error instanceof Error ? error.message : "Unknown error";
2510
+ if (message.includes("404") || message.includes("No brand voice")) {
2511
+ return {
2512
+ content: [
2513
+ {
2514
+ type: "text",
2515
+ text: "No brand voice has been configured yet. The customer can set one up at their BuzzPoster dashboard."
2516
+ }
2517
+ ]
2518
+ };
2519
+ }
2520
+ throw error;
2521
+ }
2522
+ }
2523
+ );
2524
+ server.tool(
2525
+ "update_brand_voice",
2526
+ `Create or update the customer's brand voice profile. This is an upsert \u2014 if no brand voice exists it will be created, otherwise the existing one is updated. Only the fields you provide will be changed; omitted fields are left as-is.
2527
+
2528
+ USAGE GUIDELINES:
2529
+ - Call get_brand_voice first to see the current state before making changes
2530
+ - Always set confirmed=false first to preview the changes, then confirmed=true after user approval
2531
+ - When adding rules to dos/donts, include the FULL array (existing + new items), not just the new ones
2532
+ - Platform rules use platform names as keys (e.g. twitter, linkedin, instagram)
2533
+ - Max limits: name 80 chars, description 16000 chars, dos/donts 50 items each, examplePosts 8 items`,
2534
+ {
2535
+ name: z8.string().max(80).optional().describe("Brand voice profile name (e.g. 'BOQtoday Voice', 'Lightbreak Editorial')"),
2536
+ description: z8.string().max(16e3).optional().describe("Overall voice description \u2014 tone, personality, style overview"),
2537
+ dos: z8.array(z8.string()).max(50).optional().describe("Writing rules to follow (array of do's). Send the FULL list, not just additions."),
2538
+ donts: z8.array(z8.string()).max(50).optional().describe("Things to avoid (array of don'ts). Send the FULL list, not just additions."),
2539
+ platform_rules: z8.record(z8.string()).optional().describe('Per-platform writing guidelines, e.g. { "twitter": "Keep under 200 chars, punchy tone", "linkedin": "Professional, add hashtags" }'),
2540
+ example_posts: z8.array(z8.string()).max(8).optional().describe("Example posts that demonstrate the desired voice (max 8)"),
2541
+ confirmed: z8.boolean().default(false).describe(
2542
+ "Set to true to confirm and save. If false or missing, returns a preview of what will be saved."
2543
+ )
2544
+ },
2545
+ {
2546
+ title: "Update Brand Voice",
2547
+ readOnlyHint: false,
2548
+ destructiveHint: false,
2549
+ idempotentHint: false,
2550
+ openWorldHint: false
2551
+ },
2552
+ async (args) => {
2553
+ const payload = {};
2554
+ if (args.name !== void 0) payload.name = args.name;
2555
+ if (args.description !== void 0) payload.description = args.description;
2556
+ if (args.dos !== void 0) payload.dos = args.dos;
2557
+ if (args.donts !== void 0) payload.donts = args.donts;
2558
+ if (args.platform_rules !== void 0) payload.platformRules = args.platform_rules;
2559
+ if (args.example_posts !== void 0) payload.examplePosts = args.example_posts;
2560
+ if (Object.keys(payload).length === 0) {
2561
+ return {
2562
+ content: [
2563
+ {
2564
+ type: "text",
2565
+ text: "No fields provided. Specify at least one field to update (name, description, dos, donts, platform_rules, example_posts)."
2566
+ }
2567
+ ],
2568
+ isError: true
2569
+ };
2570
+ }
2571
+ if (args.confirmed !== true) {
2572
+ const lines2 = [];
2573
+ lines2.push("## Brand Voice Update Preview");
2574
+ lines2.push("");
2575
+ if (payload.name) {
2576
+ lines2.push(`**Name:** ${payload.name}`);
2577
+ lines2.push("");
2578
+ }
2579
+ if (payload.description) {
2580
+ lines2.push("### Voice Description");
2581
+ lines2.push(String(payload.description));
2582
+ lines2.push("");
2583
+ }
2584
+ if (payload.dos) {
2585
+ const dos = payload.dos;
2586
+ lines2.push(`### Do's (${dos.length} rules)`);
2587
+ for (const rule of dos) {
2588
+ lines2.push(`- ${rule}`);
2589
+ }
2590
+ lines2.push("");
2591
+ }
2592
+ if (payload.donts) {
2593
+ const donts = payload.donts;
2594
+ lines2.push(`### Don'ts (${donts.length} rules)`);
2595
+ for (const rule of donts) {
2596
+ lines2.push(`- ${rule}`);
2597
+ }
2598
+ lines2.push("");
2599
+ }
2600
+ if (payload.platformRules) {
2601
+ const rules = payload.platformRules;
2602
+ lines2.push(`### Platform-Specific Rules (${Object.keys(rules).length} platforms)`);
2603
+ for (const [platform, rule] of Object.entries(rules)) {
2604
+ lines2.push(`**${platform}:** ${rule}`);
2605
+ }
2606
+ lines2.push("");
2607
+ }
2608
+ if (payload.examplePosts) {
2609
+ const posts = payload.examplePosts;
2610
+ lines2.push(`### Example Posts (${posts.length})`);
2611
+ posts.forEach((post, i) => {
2612
+ lines2.push(`${i + 1}. ${post}`);
2613
+ });
2614
+ lines2.push("");
2615
+ }
2616
+ lines2.push("---");
2617
+ lines2.push("Call this tool again with **confirmed=true** to save these changes.");
2618
+ return {
2619
+ content: [{ type: "text", text: lines2.join("\n") }]
2620
+ };
2621
+ }
2622
+ const result = await client.updateBrandVoice(payload);
2623
+ const lines = [];
2624
+ lines.push(`Brand voice "${result.name || "My Brand"}" has been saved successfully.`);
2625
+ lines.push("");
2626
+ const summary = [];
2627
+ if (payload.name) summary.push("name");
2628
+ if (payload.description) summary.push("description");
2629
+ if (payload.dos) summary.push(`${payload.dos.length} do's`);
2630
+ if (payload.donts) summary.push(`${payload.donts.length} don'ts`);
2631
+ if (payload.platformRules) summary.push(`${Object.keys(payload.platformRules).length} platform rules`);
2632
+ if (payload.examplePosts) summary.push(`${payload.examplePosts.length} example posts`);
2633
+ lines.push(`**Updated:** ${summary.join(", ")}`);
2634
+ return {
2635
+ content: [{ type: "text", text: lines.join("\n") }]
2636
+ };
2637
+ }
2638
+ );
2639
+ }
2640
+
2641
+ // src/tools/knowledge.ts
2642
+ import { z as z9 } from "zod";
2643
+ function registerKnowledgeTools(server, client) {
2644
+ server.tool(
2645
+ "get_knowledge_base",
2646
+ "Get all items from the customer's knowledge base. Use this to access reference material about the business, products, team, competitors, and other context that helps you write better content.",
2647
+ {},
2648
+ {
2649
+ title: "Get Knowledge Base",
2650
+ readOnlyHint: true,
2651
+ destructiveHint: false,
2652
+ idempotentHint: true,
2653
+ openWorldHint: false
2654
+ },
2655
+ async () => {
2656
+ try {
2657
+ const data = await client.listKnowledge();
2658
+ const items = data.items ?? [];
2659
+ if (items.length === 0) {
2660
+ return {
2661
+ content: [
2662
+ {
2663
+ type: "text",
2664
+ text: "The knowledge base is empty. The customer can add reference material at their BuzzPoster dashboard."
2665
+ }
2666
+ ]
2667
+ };
2668
+ }
2669
+ const totalChars = items.reduce(
2670
+ (sum, item) => sum + item.content.length,
2671
+ 0
2672
+ );
2673
+ const shouldTruncate = totalChars > 1e4;
2674
+ const lines = [];
2675
+ lines.push(`## Knowledge Base (${items.length} items)`);
2676
+ lines.push("");
2677
+ for (const item of items) {
2678
+ lines.push(`### ${item.title}`);
2679
+ if (item.tags && item.tags.length > 0) {
2680
+ lines.push(`Tags: ${item.tags.join(", ")}`);
2681
+ }
2682
+ if (shouldTruncate) {
2683
+ lines.push(item.content.slice(0, 500) + " [truncated]");
2684
+ } else {
2685
+ lines.push(item.content);
2686
+ }
2687
+ lines.push("");
2688
+ }
2689
+ return {
2690
+ content: [{ type: "text", text: lines.join("\n") }]
2691
+ };
2692
+ } catch (error) {
2693
+ const message = error instanceof Error ? error.message : "Unknown error";
2694
+ throw new Error(`Failed to get knowledge base: ${message}`);
2695
+ }
2696
+ }
2697
+ );
2698
+ server.tool(
2699
+ "search_knowledge",
2700
+ "Search the customer's knowledge base by tag or keyword. Use this to find specific reference material when writing about a topic.",
2701
+ {
2702
+ query: z9.string().describe(
2703
+ "Search query - matches against tags first, then falls back to text search on title and content"
2704
+ )
2705
+ },
2706
+ {
2707
+ title: "Search Knowledge Base",
2708
+ readOnlyHint: true,
2709
+ destructiveHint: false,
2710
+ idempotentHint: true,
2711
+ openWorldHint: false
2712
+ },
2713
+ async ({ query }) => {
2714
+ try {
2715
+ const data = await client.listKnowledge({ tag: query });
2716
+ const items = data.items ?? [];
2717
+ if (items.length === 0) {
2718
+ return {
2719
+ content: [
2720
+ {
2721
+ type: "text",
2722
+ text: `No knowledge base items found matching "${query}".`
2723
+ }
2724
+ ]
2725
+ };
2726
+ }
2727
+ const lines = [];
2728
+ lines.push(
2729
+ `## Knowledge Base Results for "${query}" (${items.length} items)`
2730
+ );
2731
+ lines.push("");
2732
+ for (const item of items) {
2733
+ lines.push(`### ${item.title}`);
2734
+ if (item.tags && item.tags.length > 0) {
2735
+ lines.push(`Tags: ${item.tags.join(", ")}`);
2736
+ }
2737
+ lines.push(item.content);
2738
+ lines.push("");
2739
+ }
2740
+ return {
2741
+ content: [{ type: "text", text: lines.join("\n") }]
2742
+ };
2743
+ } catch (error) {
2744
+ const message = error instanceof Error ? error.message : "Unknown error";
2745
+ throw new Error(`Failed to search knowledge base: ${message}`);
2746
+ }
2747
+ }
2748
+ );
2749
+ server.tool(
2750
+ "add_knowledge",
2751
+ "Add a new item to the customer's knowledge base. Use this to save useful information the customer shares during conversation.",
2752
+ {
2753
+ title: z9.string().describe("Title for the knowledge item"),
2754
+ content: z9.string().describe("The content/text to save"),
2755
+ tags: z9.array(z9.string()).optional().describe("Optional tags for categorization")
2756
+ },
2757
+ {
2758
+ title: "Add Knowledge Item",
2759
+ readOnlyHint: false,
2760
+ destructiveHint: false,
2761
+ idempotentHint: false,
2762
+ openWorldHint: false
2763
+ },
2764
+ async ({ title, content, tags }) => {
2765
+ try {
2766
+ await client.createKnowledge({
2767
+ title,
2768
+ content,
2769
+ sourceType: "text",
2770
+ tags: tags ?? []
2771
+ });
2772
+ return {
2773
+ content: [
2774
+ {
2775
+ type: "text",
2776
+ text: `Added "${title}" to the knowledge base.`
2777
+ }
2778
+ ]
2779
+ };
2780
+ } catch (error) {
2781
+ const message = error instanceof Error ? error.message : "Unknown error";
2782
+ throw new Error(`Failed to add knowledge item: ${message}`);
2783
+ }
2784
+ }
2785
+ );
2786
+ }
2787
+
2788
+ // src/tools/audience.ts
2789
+ import { z as z10 } from "zod";
2790
+ function registerAudienceTools(server, client) {
2791
+ server.tool(
2792
+ "get_audience",
2793
+ "Get the target audience profile for content creation. Use this tool to understand WHO the content is for \u2014 their demographics, pain points, motivations, preferred platforms, and tone preferences. Call this BEFORE writing any post or content so you can tailor messaging to resonate with the intended audience.",
2794
+ {
2795
+ audienceId: z10.string().optional().describe("Specific audience profile ID. If omitted, returns the default audience.")
2796
+ },
2797
+ {
2798
+ title: "Get Audience Profile",
2799
+ readOnlyHint: true,
2800
+ destructiveHint: false,
2801
+ idempotentHint: true,
2802
+ openWorldHint: false
2803
+ },
2804
+ async ({ audienceId }) => {
2805
+ try {
2806
+ const audience = audienceId ? await client.getAudience(audienceId) : await client.getDefaultAudience();
2807
+ const lines = [];
2808
+ lines.push(`## Target Audience: ${audience.name}`);
2809
+ lines.push("");
2810
+ if (audience.description) {
2811
+ lines.push("### Description");
2812
+ lines.push(audience.description);
2813
+ lines.push("");
2814
+ }
2815
+ if (audience.demographics) {
2816
+ lines.push("### Demographics");
2817
+ lines.push(audience.demographics);
2818
+ lines.push("");
2819
+ }
2820
+ if (audience.painPoints && audience.painPoints.length > 0) {
2821
+ lines.push("### Pain Points");
2822
+ for (const point of audience.painPoints) {
2823
+ lines.push(`- ${point}`);
2824
+ }
2825
+ lines.push("");
2826
+ }
2827
+ if (audience.motivations && audience.motivations.length > 0) {
2828
+ lines.push("### Motivations");
2829
+ for (const motivation of audience.motivations) {
2830
+ lines.push(`- ${motivation}`);
2831
+ }
2832
+ lines.push("");
2833
+ }
2834
+ if (audience.platforms && audience.platforms.length > 0) {
2835
+ lines.push("### Active Platforms");
2836
+ lines.push(audience.platforms.join(", "));
2837
+ lines.push("");
2838
+ }
2839
+ if (audience.toneNotes) {
2840
+ lines.push("### Tone Notes");
2841
+ lines.push(audience.toneNotes);
2842
+ lines.push("");
2843
+ }
2844
+ if (audience.contentPreferences) {
2845
+ lines.push("### Content Preferences");
2846
+ lines.push(audience.contentPreferences);
2847
+ lines.push("");
2848
+ }
2849
+ return {
2850
+ content: [{ type: "text", text: lines.join("\n") }]
2851
+ };
2852
+ } catch (error) {
2853
+ const message = error instanceof Error ? error.message : "Unknown error";
2854
+ if (message.includes("404") || message.includes("No audience")) {
2855
+ return {
2856
+ content: [
2857
+ {
2858
+ type: "text",
2859
+ text: "No audience profile has been configured yet. The customer can set one up at their BuzzPoster dashboard under Audiences."
2860
+ }
2861
+ ]
2862
+ };
2863
+ }
2864
+ throw error;
2865
+ }
2866
+ }
2867
+ );
2868
+ server.tool(
2869
+ "update_audience",
2870
+ `Create or update a target audience profile. This is an upsert \u2014 if no audience_id is provided it will try to update the default audience, or create a new one if none exists. Only the fields you provide will be changed; omitted fields are left as-is.
2871
+
2872
+ USAGE GUIDELINES:
2873
+ - Call get_audience first to see the current state before making changes
2874
+ - Always set confirmed=false first to preview the changes, then confirmed=true after user approval
2875
+ - When updating array fields (pain_points, motivations, preferred_platforms), include the FULL array (existing + new items), not just the new ones
2876
+ - Omit audience_id to update the default audience or create a new one if none exists
2877
+ - Max limits: name 100 chars, description 500 chars`,
2878
+ {
2879
+ audience_id: z10.string().optional().describe("Audience ID to update. Omit to update the default audience, or create one if none exists."),
2880
+ name: z10.string().max(100).optional().describe("Audience profile name (e.g. 'SaaS Founders', 'Health-Conscious Millennials'). Required when creating a new audience."),
2881
+ description: z10.string().max(500).optional().describe("Brief description of who this audience is"),
2882
+ demographics: z10.string().optional().describe("Demographic details \u2014 age range, location, income level, education, etc."),
2883
+ pain_points: z10.array(z10.string()).optional().describe("Problems and frustrations the audience faces. Send the FULL list, not just additions."),
2884
+ motivations: z10.array(z10.string()).optional().describe("Goals, desires, and what drives the audience. Send the FULL list, not just additions."),
2885
+ preferred_platforms: z10.array(z10.string()).optional().describe("Social platforms the audience is most active on (e.g. twitter, linkedin, instagram). Send the FULL list."),
2886
+ tone_notes: z10.string().optional().describe("How to speak to this audience \u2014 formality level, jargon preferences, emotional tone"),
2887
+ content_preferences: z10.string().optional().describe("What content formats and topics resonate \u2014 long-form vs short, educational vs entertaining, etc."),
2888
+ is_default: z10.boolean().optional().describe("Set to true to make this the default audience profile"),
2889
+ confirmed: z10.boolean().default(false).describe(
2890
+ "Set to true to confirm and save. If false or missing, returns a preview of what will be saved."
2891
+ )
2892
+ },
2893
+ {
2894
+ title: "Update Audience",
2895
+ readOnlyHint: false,
2896
+ destructiveHint: false,
2897
+ idempotentHint: false,
2898
+ openWorldHint: false
2899
+ },
2900
+ async (args) => {
2901
+ const payload = {};
2902
+ if (args.name !== void 0) payload.name = args.name;
2903
+ if (args.description !== void 0) payload.description = args.description;
2904
+ if (args.demographics !== void 0) payload.demographics = args.demographics;
2905
+ if (args.pain_points !== void 0) payload.painPoints = args.pain_points;
2906
+ if (args.motivations !== void 0) payload.motivations = args.motivations;
2907
+ if (args.preferred_platforms !== void 0) payload.platforms = args.preferred_platforms;
2908
+ if (args.tone_notes !== void 0) payload.toneNotes = args.tone_notes;
2909
+ if (args.content_preferences !== void 0) payload.contentPreferences = args.content_preferences;
2910
+ if (args.is_default !== void 0) payload.isDefault = args.is_default;
2911
+ if (Object.keys(payload).length === 0) {
2912
+ return {
2913
+ content: [
2914
+ {
2915
+ type: "text",
2916
+ text: "No fields provided. Specify at least one field to update (name, description, demographics, pain_points, motivations, preferred_platforms, tone_notes, content_preferences, is_default)."
2917
+ }
2918
+ ],
2919
+ isError: true
2920
+ };
2921
+ }
2922
+ let targetId = args.audience_id;
2923
+ let isCreating = false;
2924
+ if (!targetId) {
2925
+ try {
2926
+ const defaultAudience = await client.getDefaultAudience();
2927
+ targetId = String(defaultAudience.id);
2928
+ } catch {
2929
+ isCreating = true;
2930
+ }
2931
+ }
2932
+ if (isCreating && !payload.name) {
2933
+ return {
2934
+ content: [
2935
+ {
2936
+ type: "text",
2937
+ text: "No audience exists yet. Provide a **name** to create one (e.g. name: 'SaaS Founders')."
2938
+ }
2939
+ ],
2940
+ isError: true
2941
+ };
2942
+ }
2943
+ if (args.confirmed !== true) {
2944
+ const lines2 = [];
2945
+ lines2.push(isCreating ? "## New Audience Preview" : "## Audience Update Preview");
2946
+ if (!isCreating) {
2947
+ lines2.push(`**Audience ID:** ${targetId}`);
2948
+ }
2949
+ lines2.push("");
2950
+ if (payload.name) {
2951
+ lines2.push(`**Name:** ${payload.name}`);
2952
+ lines2.push("");
2953
+ }
2954
+ if (payload.description) {
2955
+ lines2.push("### Description");
2956
+ lines2.push(String(payload.description));
2957
+ lines2.push("");
2958
+ }
2959
+ if (payload.demographics) {
2960
+ lines2.push("### Demographics");
2961
+ lines2.push(String(payload.demographics));
2962
+ lines2.push("");
2963
+ }
2964
+ if (payload.painPoints) {
2965
+ const points = payload.painPoints;
2966
+ lines2.push(`### Pain Points (${points.length})`);
2967
+ for (const point of points) {
2968
+ lines2.push(`- ${point}`);
2969
+ }
2970
+ lines2.push("");
2971
+ }
2972
+ if (payload.motivations) {
2973
+ const motivations = payload.motivations;
2974
+ lines2.push(`### Motivations (${motivations.length})`);
2975
+ for (const motivation of motivations) {
2976
+ lines2.push(`- ${motivation}`);
2977
+ }
2978
+ lines2.push("");
2979
+ }
2980
+ if (payload.platforms) {
2981
+ const platforms = payload.platforms;
2982
+ lines2.push(`### Preferred Platforms (${platforms.length})`);
2983
+ lines2.push(platforms.join(", "));
2984
+ lines2.push("");
2985
+ }
2986
+ if (payload.toneNotes) {
2987
+ lines2.push("### Tone Notes");
2988
+ lines2.push(String(payload.toneNotes));
2989
+ lines2.push("");
2990
+ }
2991
+ if (payload.contentPreferences) {
2992
+ lines2.push("### Content Preferences");
2993
+ lines2.push(String(payload.contentPreferences));
2994
+ lines2.push("");
2995
+ }
2996
+ if (payload.isDefault !== void 0) {
2997
+ lines2.push(`**Set as default:** ${payload.isDefault ? "Yes" : "No"}`);
2998
+ lines2.push("");
2999
+ }
3000
+ lines2.push("---");
3001
+ lines2.push("Call this tool again with **confirmed=true** to save these changes.");
3002
+ return {
3003
+ content: [{ type: "text", text: lines2.join("\n") }]
3004
+ };
3005
+ }
3006
+ let result;
3007
+ if (isCreating) {
3008
+ if (payload.isDefault === void 0) payload.isDefault = true;
3009
+ result = await client.createAudience(payload);
3010
+ } else {
3011
+ result = await client.updateAudience(targetId, payload);
3012
+ }
3013
+ const lines = [];
3014
+ lines.push(`Audience "${result.name}" has been ${isCreating ? "created" : "updated"} successfully.`);
3015
+ lines.push("");
3016
+ const summary = [];
3017
+ if (payload.name) summary.push("name");
3018
+ if (payload.description) summary.push("description");
3019
+ if (payload.demographics) summary.push("demographics");
3020
+ if (payload.painPoints) summary.push(`${payload.painPoints.length} pain points`);
3021
+ if (payload.motivations) summary.push(`${payload.motivations.length} motivations`);
3022
+ if (payload.platforms) summary.push(`${payload.platforms.length} platforms`);
3023
+ if (payload.toneNotes) summary.push("tone notes");
3024
+ if (payload.contentPreferences) summary.push("content preferences");
3025
+ if (payload.isDefault !== void 0) summary.push("default status");
3026
+ lines.push(`**${isCreating ? "Created with" : "Updated"}:** ${summary.join(", ")}`);
3027
+ return {
3028
+ content: [{ type: "text", text: lines.join("\n") }]
3029
+ };
3030
+ }
3031
+ );
3032
+ }
3033
+
3034
+ // src/tools/newsletter-template.ts
3035
+ import { z as z11 } from "zod";
3036
+ var NEWSLETTER_CRAFT_GUIDE = `## Newsletter Craft Guide
3037
+
3038
+ Follow these rules when drafting:
3039
+
3040
+ ### HTML Email Rules
3041
+ - Use table-based layouts, inline CSS only, 600px max width
3042
+ - Email-safe fonts only: Arial, Helvetica, Georgia, Verdana
3043
+ - Keep total email under 102KB (Gmail clips larger)
3044
+ - All images must use absolute URLs hosted on the customer's R2 CDN
3045
+ - Include alt text on every image
3046
+ - Use role="presentation" on layout tables
3047
+ - No JavaScript, no forms, no CSS grid/flexbox, no background images
3048
+
3049
+ ### Structure
3050
+ - Subject line: 6-10 words, specific not clickbaity
3051
+ - Preview text: complements the subject, doesn't repeat it
3052
+ - Open strong -- lead with the most interesting thing
3053
+ - One clear CTA per newsletter
3054
+ - Sign off should feel human, not corporate
3055
+
3056
+ ### Personalization
3057
+ - Kit: use {{ subscriber.first_name }}
3058
+ - Beehiiv: use {{email}} or {{subscriber_id}}
3059
+ - Mailchimp: use *NAME|*
3060
+
3061
+ ### Images
3062
+ - Upload to customer's R2 CDN via upload_media or upload_from_url
3063
+ - Reference with full CDN URLs in img tags
3064
+ - Always set width, height, and alt attributes
3065
+ - Use style="display:block;" to prevent phantom spacing`;
3066
+ function registerNewsletterTemplateTools(server, client) {
3067
+ server.tool(
3068
+ "get_newsletter_template",
3069
+ "Get the customer's newsletter template -- the blueprint for how their newsletter is structured. IMPORTANT: Call this BEFORE writing any newsletter. The template defines the exact section order, what each section should contain, word counts, content sources, and styling. Follow the template structure precisely when generating newsletter content. If no template_id is specified, returns the default template. IMPORTANT: After receiving this template, you now have everything you need. Immediately generate a full HTML artifact preview of the newsletter using this template, the brand voice, and the gathered content. Do NOT summarize the template in text. Do NOT ask the user what they think. Render the complete HTML newsletter artifact now.",
3070
+ {
3071
+ templateId: z11.string().optional().describe(
3072
+ "Specific template ID. If omitted, returns the default template."
3073
+ )
3074
+ },
3075
+ {
3076
+ title: "Get Newsletter Template",
3077
+ readOnlyHint: true,
3078
+ destructiveHint: false,
3079
+ idempotentHint: true,
3080
+ openWorldHint: false
3081
+ },
3082
+ async ({ templateId }) => {
3083
+ try {
3084
+ const template = await client.getTemplate(templateId);
3085
+ const lines = [];
3086
+ lines.push(`## Newsletter Template: ${template.name}`);
3087
+ if (template.description) lines.push(template.description);
3088
+ lines.push("");
3089
+ if (template.audienceId)
3090
+ lines.push(`Audience ID: ${template.audienceId}`);
3091
+ if (template.sendCadence)
3092
+ lines.push(`Cadence: ${template.sendCadence}`);
3093
+ if (template.subjectPattern)
3094
+ lines.push(`Subject pattern: ${template.subjectPattern}`);
3095
+ if (template.previewTextPattern)
3096
+ lines.push(`Preview text pattern: ${template.previewTextPattern}`);
3097
+ lines.push("");
3098
+ if (template.sections && template.sections.length > 0) {
3099
+ lines.push("### Sections (in order):");
3100
+ lines.push("");
3101
+ for (let i = 0; i < template.sections.length; i++) {
3102
+ const s = template.sections[i];
3103
+ lines.push(`**${i + 1}. ${s.label}** (${s.type})`);
3104
+ if (s.instructions)
3105
+ lines.push(`Instructions: ${s.instructions}`);
3106
+ if (s.word_count)
3107
+ lines.push(
3108
+ `Word count: ${s.word_count.min}-${s.word_count.max} words`
3109
+ );
3110
+ if (s.tone_override) lines.push(`Tone: ${s.tone_override}`);
3111
+ if (s.content_source) {
3112
+ const src = s.content_source;
3113
+ if (src.type === "rss" && src.feed_urls?.length)
3114
+ lines.push(`Content source: RSS - ${src.feed_urls.join(", ")}`);
3115
+ else if (src.type === "knowledge" && src.knowledge_item_ids?.length)
3116
+ lines.push(
3117
+ `Content source: Knowledge items ${src.knowledge_item_ids.join(", ")}`
3118
+ );
3119
+ else lines.push(`Content source: ${src.type}`);
3120
+ }
3121
+ if (s.count) lines.push(`Count: ${s.count} items`);
3122
+ lines.push(
3123
+ `Required: ${s.required !== false ? "yes" : "no"}`
3124
+ );
3125
+ if (s.type === "cta") {
3126
+ if (s.button_text) lines.push(`Button text: ${s.button_text}`);
3127
+ if (s.button_url) lines.push(`Button URL: ${s.button_url}`);
3128
+ }
3129
+ if (s.type === "sponsor") {
3130
+ if (s.sponsor_name)
3131
+ lines.push(`Sponsor: ${s.sponsor_name}`);
3132
+ if (s.talking_points)
3133
+ lines.push(`Talking points: ${s.talking_points}`);
3134
+ }
3135
+ if (s.type === "header") {
3136
+ if (s.tagline) lines.push(`Tagline: ${s.tagline}`);
3137
+ }
3138
+ if (s.type === "signoff") {
3139
+ if (s.author_name)
3140
+ lines.push(`Author: ${s.author_name}`);
3141
+ if (s.author_title)
3142
+ lines.push(`Title: ${s.author_title}`);
3143
+ }
3144
+ lines.push("");
3145
+ }
3146
+ }
3147
+ if (template.style) {
3148
+ lines.push("### Style Guide:");
3149
+ const st = template.style;
3150
+ if (st.primary_color) lines.push(`Primary color: ${st.primary_color}`);
3151
+ if (st.accent_color) lines.push(`Accent color: ${st.accent_color}`);
3152
+ if (st.font_family) lines.push(`Font: ${st.font_family}`);
3153
+ if (st.heading_font) lines.push(`Heading font: ${st.heading_font}`);
3154
+ if (st.content_width)
3155
+ lines.push(`Content width: ${st.content_width}px`);
3156
+ if (st.text_color) lines.push(`Text color: ${st.text_color}`);
3157
+ if (st.link_color) lines.push(`Link color: ${st.link_color}`);
3158
+ if (st.background_color)
3159
+ lines.push(`Background: ${st.background_color}`);
3160
+ if (st.button_style)
3161
+ lines.push(
3162
+ `Button style: ${st.button_style.color} text on ${st.button_style.text_color}, ${st.button_style.shape}`
3163
+ );
3164
+ lines.push("");
3165
+ lines.push(
3166
+ "NOTE: Apply these style values as inline CSS when generating the newsletter HTML."
3167
+ );
3168
+ lines.push("");
3169
+ }
3170
+ if (template.rssFeedUrls && template.rssFeedUrls.length > 0) {
3171
+ lines.push("### Linked RSS Feeds:");
3172
+ for (const url of template.rssFeedUrls) {
3173
+ lines.push(`- ${url}`);
3174
+ }
3175
+ lines.push("");
3176
+ }
3177
+ if (template.knowledgeItemIds && template.knowledgeItemIds.length > 0) {
3178
+ lines.push("### Reference Material:");
3179
+ lines.push(
3180
+ `Knowledge item IDs: ${template.knowledgeItemIds.join(", ")}`
3181
+ );
3182
+ lines.push("");
3183
+ }
3184
+ lines.push(NEWSLETTER_CRAFT_GUIDE);
3185
+ return {
3186
+ content: [{ type: "text", text: lines.join("\n") }]
3187
+ };
3188
+ } catch (error) {
3189
+ const message = error instanceof Error ? error.message : "Unknown error";
3190
+ if (message.includes("404") || message.includes("No newsletter templates")) {
3191
+ return {
3192
+ content: [
3193
+ {
3194
+ type: "text",
3195
+ text: "No newsletter template configured. Ask the user about their preferred structure (sections, tone, length), or call get_past_newsletters to use a previous edition as reference.\n\n" + NEWSLETTER_CRAFT_GUIDE
3196
+ }
3197
+ ]
3198
+ };
3199
+ }
3200
+ throw error;
3201
+ }
3202
+ }
3203
+ );
3204
+ server.tool(
3205
+ "get_past_newsletters",
3206
+ "Retrieve the customer's past newsletters for reference. Use this to maintain continuity, match previous formatting, avoid repeating topics, and reference previous editions. Call this when writing a new newsletter to see what was covered recently.",
3207
+ {
3208
+ limit: z11.number().optional().describe("Number of past newsletters to retrieve. Default 5, max 50."),
3209
+ templateId: z11.string().optional().describe("Filter by template ID to see past newsletters from a specific template.")
3210
+ },
3211
+ {
3212
+ title: "Get Past Newsletters",
3213
+ readOnlyHint: true,
3214
+ destructiveHint: false,
3215
+ idempotentHint: true,
3216
+ openWorldHint: false
3217
+ },
3218
+ async ({ limit, templateId }) => {
3219
+ try {
3220
+ const params = {};
3221
+ if (limit) params.limit = String(Math.min(limit, 50));
3222
+ else params.limit = "5";
3223
+ if (templateId) params.template_id = templateId;
3224
+ const newsletters = await client.listNewsletterArchive(
3225
+ params
3226
+ );
3227
+ if (!newsletters || newsletters.length === 0) {
3228
+ return {
3229
+ content: [
3230
+ {
3231
+ type: "text",
3232
+ text: "No past newsletters found. This will be the first edition!"
3233
+ }
3234
+ ]
3235
+ };
3236
+ }
3237
+ const lines = [];
3238
+ lines.push(
3239
+ `## Past Newsletters (${newsletters.length} most recent)`
3240
+ );
3241
+ lines.push("");
3242
+ for (const nl of newsletters) {
3243
+ lines.push(`### ${nl.subject} -- ${nl.sentAt || nl.createdAt}`);
3244
+ if (nl.notes) lines.push(`Notes: ${nl.notes}`);
3245
+ if (nl.metrics) {
3246
+ const m = nl.metrics;
3247
+ const parts = [];
3248
+ if (m.open_rate) parts.push(`Open rate: ${m.open_rate}`);
3249
+ if (m.click_rate) parts.push(`Click rate: ${m.click_rate}`);
3250
+ if (parts.length) lines.push(`Metrics: ${parts.join(", ")}`);
3251
+ }
3252
+ lines.push("---");
3253
+ const textContent = nl.contentHtml ? nl.contentHtml.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim().slice(0, 500) : "(no content)";
3254
+ lines.push(textContent);
3255
+ lines.push(
3256
+ `[Full content available via get_past_newsletter tool with ID: ${nl.id}]`
3257
+ );
3258
+ lines.push("");
3259
+ }
3260
+ return {
3261
+ content: [{ type: "text", text: lines.join("\n") }]
3262
+ };
3263
+ } catch (error) {
3264
+ const message = error instanceof Error ? error.message : "Unknown error";
3265
+ throw new Error(`Failed to fetch past newsletters: ${message}`);
3266
+ }
3267
+ }
3268
+ );
3269
+ server.tool(
3270
+ "get_past_newsletter",
3271
+ "Get the full content of a specific past newsletter by ID. Use when you need to reference the complete text of a previous edition, check exact formatting, or continue a series.",
3272
+ {
3273
+ newsletterId: z11.string().describe("The ID of the archived newsletter to retrieve.")
3274
+ },
3275
+ {
3276
+ title: "Get Past Newsletter Detail",
3277
+ readOnlyHint: true,
3278
+ destructiveHint: false,
3279
+ idempotentHint: true,
3280
+ openWorldHint: false
3281
+ },
3282
+ async ({ newsletterId }) => {
3283
+ try {
3284
+ const newsletter = await client.getArchivedNewsletter(
3285
+ newsletterId
3286
+ );
3287
+ const lines = [];
3288
+ lines.push(`## ${newsletter.subject}`);
3289
+ if (newsletter.sentAt) lines.push(`Sent: ${newsletter.sentAt}`);
3290
+ if (newsletter.notes) lines.push(`Notes: ${newsletter.notes}`);
3291
+ lines.push("");
3292
+ lines.push("### Full HTML Content:");
3293
+ lines.push(newsletter.contentHtml);
3294
+ return {
3295
+ content: [{ type: "text", text: lines.join("\n") }]
3296
+ };
3297
+ } catch (error) {
3298
+ const message = error instanceof Error ? error.message : "Unknown error";
3299
+ if (message.includes("404")) {
3300
+ return {
3301
+ content: [
3302
+ {
3303
+ type: "text",
3304
+ text: "Newsletter not found. Check the ID and try again."
3305
+ }
3306
+ ]
3307
+ };
3308
+ }
3309
+ throw error;
3310
+ }
3311
+ }
3312
+ );
3313
+ server.tool(
3314
+ "save_newsletter",
3315
+ "Save a generated newsletter to the archive. Call this after the customer approves a newsletter you've written, so it's stored for future reference. Include the subject line and full HTML content.",
3316
+ {
3317
+ subject: z11.string().describe("The newsletter subject line."),
3318
+ contentHtml: z11.string().describe("The full HTML content of the newsletter."),
3319
+ templateId: z11.string().optional().describe("The template ID used to generate this newsletter."),
3320
+ notes: z11.string().optional().describe(
3321
+ "Optional notes about this newsletter (what worked, theme, etc)."
3322
+ )
3323
+ },
3324
+ {
3325
+ title: "Save Newsletter to Archive",
3326
+ readOnlyHint: false,
3327
+ destructiveHint: false,
3328
+ idempotentHint: false,
3329
+ openWorldHint: false
3330
+ },
3331
+ async ({ subject, contentHtml, templateId, notes }) => {
3332
+ try {
3333
+ const data = {
3334
+ subject,
3335
+ contentHtml
3336
+ };
3337
+ if (templateId) data.templateId = Number(templateId);
3338
+ if (notes) data.notes = notes;
3339
+ await client.saveNewsletterToArchive(data);
3340
+ return {
3341
+ content: [
3342
+ {
3343
+ type: "text",
3344
+ text: `Newsletter "${subject}" has been saved to the archive successfully.`
3345
+ }
3346
+ ]
3347
+ };
3348
+ } catch (error) {
3349
+ const message = error instanceof Error ? error.message : "Unknown error";
3350
+ throw new Error(`Failed to save newsletter: ${message}`);
3351
+ }
3352
+ }
3353
+ );
3354
+ }
3355
+
3356
+ // src/tools/calendar.ts
3357
+ import { z as z12 } from "zod";
3358
+ function registerCalendarTools(server, client) {
3359
+ server.tool(
3360
+ "get_calendar",
3361
+ "View the customer's content calendar -- all scheduled, published, and draft posts across social media and newsletters. Use this to check what's already scheduled before creating new content, avoid posting conflicts, and maintain a balanced content mix.",
3362
+ {
3363
+ from: z12.string().optional().describe("ISO date to filter from, e.g. 2024-01-01"),
3364
+ to: z12.string().optional().describe("ISO date to filter to, e.g. 2024-01-31"),
3365
+ status: z12.string().optional().describe("Filter by status: scheduled, published, draft, failed"),
3366
+ type: z12.string().optional().describe("Filter by type: social_post or newsletter")
3367
+ },
3368
+ {
3369
+ title: "Get Content Calendar",
3370
+ readOnlyHint: true,
3371
+ destructiveHint: false,
3372
+ idempotentHint: true,
3373
+ openWorldHint: false
3374
+ },
3375
+ async (args) => {
3376
+ const params = {};
3377
+ if (args.from) params.from = args.from;
3378
+ if (args.to) params.to = args.to;
3379
+ if (args.status) params.status = args.status;
3380
+ if (args.type) params.type = args.type;
3381
+ const data = await client.getCalendar(params);
3382
+ const items = data?.items ?? [];
3383
+ const scheduled = items.filter((i) => i.status === "scheduled");
3384
+ const published = items.filter((i) => i.status === "published");
3385
+ let text = "## Content Calendar\n\n";
3386
+ if (scheduled.length > 0) {
3387
+ text += "### Scheduled\n";
3388
+ for (const item of scheduled) {
3389
+ const date = item.scheduled_for ? new Date(item.scheduled_for).toLocaleString() : "TBD";
3390
+ if (item.type === "newsletter") {
3391
+ text += `\u{1F4E7} ${date} -- Newsletter: "${item.content_preview}"
3392
+ `;
3393
+ } else {
3394
+ const platforms = (item.platforms ?? []).join(", ");
3395
+ text += `\u{1F4C5} ${date} -- ${platforms} -- "${item.content_preview}"
3396
+ `;
3397
+ }
3398
+ }
3399
+ text += "\n";
3400
+ } else {
3401
+ text += "### Scheduled\nNo scheduled content.\n\n";
3402
+ }
3403
+ if (published.length > 0) {
3404
+ text += "### Recently Published\n";
3405
+ for (const item of published.slice(0, 10)) {
3406
+ const date = item.published_at ? new Date(item.published_at).toLocaleString() : "";
3407
+ if (item.type === "newsletter") {
3408
+ text += `\u2705 ${date} -- Newsletter: "${item.content_preview}"
3409
+ `;
3410
+ } else {
3411
+ const platforms = (item.platforms ?? []).join(", ");
3412
+ text += `\u2705 ${date} -- ${platforms} -- "${item.content_preview}"
3413
+ `;
3414
+ }
3415
+ }
3416
+ text += "\n";
3417
+ }
3418
+ try {
3419
+ const nextSlot = await client.getNextSlot();
3420
+ if (nextSlot?.scheduledFor) {
3421
+ const slotDate = new Date(nextSlot.scheduledFor);
3422
+ const dayNames = [
3423
+ "Sunday",
3424
+ "Monday",
3425
+ "Tuesday",
3426
+ "Wednesday",
3427
+ "Thursday",
3428
+ "Friday",
3429
+ "Saturday"
3430
+ ];
3431
+ text += `### Queue: Next slot
3432
+ \u23ED\uFE0F ${slotDate.toLocaleString()} (${dayNames[slotDate.getDay()]})
3433
+ `;
3434
+ }
3435
+ } catch {
3436
+ }
3437
+ return {
3438
+ content: [{ type: "text", text }]
3439
+ };
3440
+ }
3441
+ );
3442
+ server.tool(
3443
+ "get_queue",
3444
+ "View the customer's posting queue schedule -- their recurring weekly time slots for automatic post scheduling. Use this to understand when posts go out and to schedule content into the next available slot.",
3445
+ {},
3446
+ {
3447
+ title: "Get Posting Queue",
3448
+ readOnlyHint: true,
3449
+ destructiveHint: false,
3450
+ idempotentHint: true,
3451
+ openWorldHint: false
3452
+ },
3453
+ async () => {
3454
+ const data = await client.getQueue();
3455
+ const dayNames = [
3456
+ "Sunday",
3457
+ "Monday",
3458
+ "Tuesday",
3459
+ "Wednesday",
3460
+ "Thursday",
3461
+ "Friday",
3462
+ "Saturday"
3463
+ ];
3464
+ let text = "## Posting Queue\n\n";
3465
+ text += `Timezone: ${data?.timezone ?? "UTC"}
3466
+ `;
3467
+ text += `Active: ${data?.active ? "Yes" : "No"}
3468
+
3469
+ `;
3470
+ const slots = data?.slots ?? [];
3471
+ if (slots.length === 0) {
3472
+ text += "No queue slots configured.\n";
3473
+ } else {
3474
+ text += "### Weekly Schedule\n";
3475
+ for (const slot of slots) {
3476
+ const day = dayNames[slot.dayOfWeek] ?? `Day ${slot.dayOfWeek}`;
3477
+ const platforms = (slot.platforms ?? []).map((p) => `[${p}]`).join(" ");
3478
+ text += `${day}: ${slot.time} ${platforms}
3479
+ `;
3480
+ }
3481
+ }
3482
+ try {
3483
+ const nextSlot = await client.getNextSlot();
3484
+ if (nextSlot?.scheduledFor) {
3485
+ const slotDate = new Date(nextSlot.scheduledFor);
3486
+ text += `
3487
+ Next slot: ${slotDate.toLocaleString()} (${dayNames[slotDate.getDay()]})
3488
+ `;
3489
+ }
3490
+ } catch {
3491
+ }
3492
+ return {
3493
+ content: [{ type: "text", text }]
3494
+ };
3495
+ }
3496
+ );
3497
+ server.tool(
3498
+ "schedule_to_queue",
3499
+ `Add a post to the customer's content queue for automatic scheduling.
3500
+
3501
+ Always show the user a preview of the content before calling this tool. Call get_brand_voice and get_audience before composing if not already loaded this session. Never call with confirmed=true without explicit user approval.
3502
+
3503
+ REQUIRED WORKFLOW:
3504
+ 1. BEFORE creating content, call get_brand_voice and get_audience
3505
+ 2. ALWAYS set confirmed=false first to preview
3506
+ 3. Show the user a visual preview before confirming
3507
+ 4. Tell the user which queue slot the post will be assigned to
3508
+
3509
+ When confirmed=false, this tool returns a structured preview with content, platform details, safety checks, and the assigned queue slot. You MUST render this as a visual preview for the user and wait for their explicit approval before calling again with confirmed=true.`,
3510
+ {
3511
+ content: z12.string().describe("The text content of the post"),
3512
+ platforms: z12.array(z12.string()).describe('Platforms to post to, e.g. ["twitter", "linkedin"]'),
3513
+ media_urls: z12.array(z12.string()).optional().describe("Public URLs of media to attach"),
3514
+ confirmed: z12.boolean().default(false).describe(
3515
+ "Set to true to confirm scheduling. If false or missing, returns a preview for user approval."
3516
+ )
3517
+ },
3518
+ {
3519
+ title: "Schedule Post to Queue",
3520
+ readOnlyHint: false,
3521
+ destructiveHint: false,
3522
+ idempotentHint: false,
3523
+ openWorldHint: true
3524
+ },
3525
+ async (args) => {
3526
+ const nextSlot = await client.getNextSlot();
3527
+ if (!nextSlot?.scheduledFor) {
3528
+ return {
3529
+ content: [
3530
+ {
3531
+ type: "text",
3532
+ text: "No queue slots configured. Set up your posting queue first using the dashboard or ask to configure queue slots."
3533
+ }
3534
+ ]
3535
+ };
3536
+ }
3537
+ if (args.confirmed !== true) {
3538
+ const slotDate2 = new Date(nextSlot.scheduledFor);
3539
+ let rulesText = "";
3540
+ try {
3541
+ const rules = await client.getPublishingRules();
3542
+ const blockedWords = rules.blockedWords ?? [];
3543
+ const foundBlocked = [];
3544
+ if (args.content && blockedWords.length > 0) {
3545
+ const lower = args.content.toLowerCase();
3546
+ for (const word of blockedWords) {
3547
+ if (lower.includes(word.toLowerCase())) foundBlocked.push(word);
3548
+ }
3549
+ }
3550
+ rulesText = `
3551
+ ### Safety Checks
3552
+ `;
3553
+ rulesText += `- Blocked words: ${foundBlocked.length > 0 ? `**FAILED** (found: ${foundBlocked.join(", ")})` : "PASS"}
3554
+ `;
3555
+ } catch {
3556
+ }
3557
+ const preview = `## Queue Post Preview
3558
+
3559
+ **Content:** "${args.content}"
3560
+ **Platforms:** ${args.platforms.join(", ")}
3561
+ **Next queue slot:** ${slotDate2.toLocaleString()}
3562
+ ` + (args.media_urls?.length ? `**Media:** ${args.media_urls.length} file(s)
3563
+ ` : "") + rulesText + `
3564
+ Call this tool again with confirmed=true to schedule.`;
3565
+ return { content: [{ type: "text", text: preview }] };
3566
+ }
3567
+ const body = {
3568
+ content: args.content,
3569
+ platforms: args.platforms.map((p) => ({ platform: p })),
3570
+ scheduledFor: nextSlot.scheduledFor,
3571
+ timezone: nextSlot.timezone ?? "UTC"
3572
+ };
3573
+ if (args.media_urls?.length) {
3574
+ body.mediaItems = args.media_urls.map((url) => ({
3575
+ type: url.match(/\.(mp4|mov|avi|webm)$/i) ? "video" : "image",
3576
+ url
3577
+ }));
3578
+ }
3579
+ const result = await client.createPost(body);
3580
+ const slotDate = new Date(nextSlot.scheduledFor);
3581
+ return {
3582
+ content: [
3583
+ {
3584
+ type: "text",
3585
+ text: `Post scheduled to queue slot: ${slotDate.toLocaleString()}
3586
+
3587
+ ${JSON.stringify(result, null, 2)}`
3588
+ }
3589
+ ]
3590
+ };
3591
+ }
3592
+ );
3593
+ }
3594
+
3595
+ // src/tools/notifications.ts
3596
+ import { z as z13 } from "zod";
3597
+ function timeAgo(dateStr) {
3598
+ const diff = Date.now() - new Date(dateStr).getTime();
3599
+ const minutes = Math.floor(diff / 6e4);
3600
+ if (minutes < 1) return "just now";
3601
+ if (minutes < 60) return `${minutes}m ago`;
3602
+ const hours = Math.floor(minutes / 60);
3603
+ if (hours < 24) return `${hours}h ago`;
3604
+ const days = Math.floor(hours / 24);
3605
+ return `${days}d ago`;
3606
+ }
3607
+ function registerNotificationTools(server, client) {
3608
+ server.tool(
3609
+ "get_notifications",
3610
+ "Get recent notifications including post publishing results, account health warnings, and errors. Use this to check if any posts failed or if any accounts need attention.",
3611
+ {
3612
+ unread_only: z13.boolean().optional().describe("If true, only show unread notifications. Default false."),
3613
+ limit: z13.number().optional().describe("Max notifications to return. Default 10.")
3614
+ },
3615
+ {
3616
+ title: "Get Notifications",
3617
+ readOnlyHint: true,
3618
+ destructiveHint: false,
3619
+ idempotentHint: true,
3620
+ openWorldHint: false
3621
+ },
3622
+ async (args) => {
3623
+ const params = {};
3624
+ if (args.unread_only) params.unread = "true";
3625
+ if (args.limit) params.limit = String(args.limit);
3626
+ else params.limit = "10";
3627
+ const result = await client.getNotifications(params);
3628
+ const notifications = result.notifications ?? [];
3629
+ const unreadCount = result.unread_count ?? 0;
3630
+ if (notifications.length === 0) {
3631
+ return {
3632
+ content: [{
3633
+ type: "text",
3634
+ text: `## Notifications${args.unread_only ? " (unread)" : ""}
3635
+
3636
+ No notifications found. All clear!`
3637
+ }]
3638
+ };
3639
+ }
3640
+ const severityIcon = {
3641
+ success: "\u{1F7E2}",
3642
+ warning: "\u{1F7E1}",
3643
+ error: "\u{1F534}",
3644
+ info: "\u{1F535}"
3645
+ };
3646
+ const lines = notifications.map((n) => {
3647
+ const icon = severityIcon[n.severity] ?? "\u2B1C";
3648
+ const time = n.createdAt ? timeAgo(n.createdAt) : "";
3649
+ let line = `${icon} **${time}** \u2014 ${n.title}
3650
+ "${n.message}"`;
3651
+ if (n.actionUrl || n.actionLabel) {
3652
+ const action = n.actionLabel || "View";
3653
+ if (n.resourceType === "post" && n.resourceId) {
3654
+ line += `
3655
+ Action: ${action} \u2192 retry_post with post_id "${n.resourceId}"`;
3656
+ } else {
3657
+ line += `
3658
+ Action: ${action} \u2192 ${n.actionUrl}`;
3659
+ }
3660
+ }
3661
+ return line;
3662
+ });
3663
+ const header = `## Notifications (${unreadCount} unread)
3664
+
3665
+ `;
3666
+ const footer = `
3667
+
3668
+ Showing ${notifications.length} of ${result.unread_count !== void 0 ? "total" : ""} notifications.`;
3669
+ return {
3670
+ content: [{
3671
+ type: "text",
3672
+ text: header + lines.join("\n\n") + footer
3673
+ }]
3674
+ };
3675
+ }
3676
+ );
3677
+ }
3678
+
3679
+ // src/tools/publishing-rules.ts
3680
+ function registerPublishingRulesTools(server, client) {
3681
+ server.tool(
3682
+ "get_publishing_rules",
3683
+ `Get the customer's publishing safety rules and default behaviors. IMPORTANT: Call this tool BEFORE publishing, scheduling, or sending any content. These rules define whether content should default to draft, whether previews are required, and what confirmations are needed before going live. Always respect these rules -- they exist to prevent accidental publishing.`,
3684
+ {},
3685
+ {
3686
+ title: "Get Publishing Rules",
3687
+ readOnlyHint: true,
3688
+ destructiveHint: false,
3689
+ idempotentHint: true,
3690
+ openWorldHint: false
3691
+ },
3692
+ async () => {
3693
+ const rules = await client.getPublishingRules();
3694
+ let text = "## Publishing Rules\n\n";
3695
+ text += `Social media default: **${rules.socialDefaultAction ?? "draft"}**`;
3696
+ if (rules.socialDefaultAction === "draft") text += " (content is saved as draft, not published)";
3697
+ text += "\n";
3698
+ text += `Newsletter default: **${rules.newsletterDefaultAction ?? "draft"}**`;
3699
+ if (rules.newsletterDefaultAction === "draft") text += " (content is saved as draft, not sent)";
3700
+ text += "\n";
3701
+ text += `Preview required before publish: **${rules.requirePreviewBeforePublish ? "yes" : "no"}**
3702
+ `;
3703
+ text += `Double confirmation for newsletters: **${rules.requireDoubleConfirmNewsletter ? "yes" : "no"}**
3704
+ `;
3705
+ text += `Double confirmation for social: **${rules.requireDoubleConfirmSocial ? "yes" : "no"}**
3706
+ `;
3707
+ text += `Immediate publish allowed: **${rules.allowImmediatePublish ? "yes" : "no"}**
3708
+ `;
3709
+ text += `Immediate send allowed: **${rules.allowImmediateSend ? "yes" : "no"}**
3710
+ `;
3711
+ text += `Max posts per day: **${rules.maxPostsPerDay ?? "unlimited"}**
3712
+ `;
3713
+ if (rules.blockedWords && rules.blockedWords.length > 0) {
3714
+ text += `Blocked words: **${rules.blockedWords.join(", ")}**
3715
+ `;
3716
+ } else {
3717
+ text += "Blocked words: none\n";
3718
+ }
3719
+ if (rules.requiredDisclaimer) {
3720
+ text += `Required disclaimer: "${rules.requiredDisclaimer}"
3721
+ `;
3722
+ } else {
3723
+ text += "Required disclaimer: none\n";
3724
+ }
3725
+ return { content: [{ type: "text", text }] };
3726
+ }
3727
+ );
3728
+ }
3729
+
3730
+ // src/tools/audit-log.ts
3731
+ import { z as z14 } from "zod";
3732
+ function registerAuditLogTools(server, client) {
3733
+ server.tool(
3734
+ "get_audit_log",
3735
+ "View the history of all publish, send, schedule, and delete actions taken on the customer's account. Use this to review what was published recently, check activity, and troubleshoot issues.",
3736
+ {
3737
+ limit: z14.number().optional().describe("Maximum number of entries to return (default 20, max 200)"),
3738
+ action: z14.string().optional().describe("Filter by action type prefix, e.g. 'post.published', 'newsletter.sent', 'post.' for all post actions"),
3739
+ resource_type: z14.string().optional().describe("Filter by resource type: post, newsletter, account, media, publishing_rules, brand_voice")
3740
+ },
3741
+ {
3742
+ title: "Get Audit Log",
3743
+ readOnlyHint: true,
3744
+ destructiveHint: false,
3745
+ idempotentHint: true,
3746
+ openWorldHint: false
3747
+ },
3748
+ async (args) => {
3749
+ const params = {};
3750
+ if (args.limit) params.limit = String(args.limit);
3751
+ if (args.action) params.action = args.action;
3752
+ if (args.resource_type) params.resource_type = args.resource_type;
3753
+ const data = await client.getAuditLog(params);
3754
+ const entries = data?.entries ?? [];
3755
+ if (entries.length === 0) {
3756
+ return {
3757
+ content: [{ type: "text", text: "## Audit Log\n\nNo entries found." }]
3758
+ };
3759
+ }
3760
+ let text = `## Audit Log (${entries.length} of ${data?.total ?? entries.length} entries)
3761
+
3762
+ `;
3763
+ for (const entry of entries) {
3764
+ const date = entry.createdAt ? new Date(entry.createdAt).toLocaleString() : "unknown";
3765
+ const details = entry.details && Object.keys(entry.details).length > 0 ? ` \u2014 ${JSON.stringify(entry.details)}` : "";
3766
+ text += `- **${date}** | ${entry.action} | ${entry.resourceType}`;
3767
+ if (entry.resourceId) text += ` #${entry.resourceId}`;
3768
+ text += `${details}
3769
+ `;
3770
+ }
3771
+ return { content: [{ type: "text", text }] };
3772
+ }
3773
+ );
3774
+ }
3775
+
3776
+ // src/tools/sources.ts
3777
+ import { z as z15 } from "zod";
3778
+ var TYPE_LABELS = {
3779
+ feed: "RSS Feed",
3780
+ website: "Website",
3781
+ youtube: "YouTube Channel",
3782
+ search: "Search Topic",
3783
+ podcast: "Podcast",
3784
+ reddit: "Reddit",
3785
+ social: "Social Profile"
3786
+ };
3787
+ var TYPE_HINTS = {
3788
+ feed: "Use fetch_feed to get latest entries",
3789
+ website: "Use fetch_article to extract content",
3790
+ youtube: "Use fetch_feed to get latest videos (YouTube RSS)",
3791
+ search: "Use web_search with the searchQuery",
3792
+ podcast: "Use fetch_feed to get latest episodes",
3793
+ reddit: "Use fetch_feed to get latest posts (Reddit RSS)",
3794
+ social: "Use web_search to check recent activity"
3795
+ };
3796
+ function registerSourceTools(server, client) {
3797
+ server.tool(
3798
+ "get_sources",
3799
+ "Get the customer's saved content sources \u2014 their curated list of RSS feeds, websites, YouTube channels, social accounts, and search topics that they monitor for content ideas and inspiration. Call this when the user asks 'what should I post about?', 'check my sources', 'what's new?', 'find me content ideas', or references their content sources. After getting sources, use fetch_feed (for feed/podcast/reddit/youtube types), fetch_article (for website types), or web_search (for search/social types) to actually retrieve the latest content from each source.",
3800
+ {
3801
+ type: z15.string().optional().describe(
3802
+ "Optional: filter by type (feed, website, youtube, search, podcast, reddit, social)"
3803
+ ),
3804
+ category: z15.string().optional().describe("Optional: filter by category")
3805
+ },
3806
+ {
3807
+ title: "Get Content Sources",
3808
+ readOnlyHint: true,
3809
+ destructiveHint: false,
3810
+ idempotentHint: true,
3811
+ openWorldHint: false
3812
+ },
3813
+ async (args) => {
3814
+ const params = {};
3815
+ if (args.type) params.type = args.type;
3816
+ if (args.category) params.category = args.category;
3817
+ const result = await client.listSources(params);
3818
+ const sources = result.sources;
3819
+ if (sources.length === 0) {
3820
+ return {
3821
+ content: [
3822
+ {
3823
+ type: "text",
3824
+ text: "No content sources saved yet. Use add_source to save RSS feeds, websites, YouTube channels, or search topics the customer wants to monitor for content ideas."
3825
+ }
3826
+ ]
3827
+ };
3828
+ }
3829
+ const lines = [`## Content Sources (${sources.length})
3830
+ `];
3831
+ const byCategory = /* @__PURE__ */ new Map();
3832
+ for (const s of sources) {
3833
+ const cat = s.category || "Uncategorized";
3834
+ if (!byCategory.has(cat)) byCategory.set(cat, []);
3835
+ byCategory.get(cat).push(s);
3836
+ }
3837
+ for (const [cat, items] of byCategory) {
3838
+ lines.push(`### ${cat}
3839
+ `);
3840
+ for (const s of items) {
3841
+ const typeLabel = TYPE_LABELS[s.type] || s.type;
3842
+ const hint = TYPE_HINTS[s.type] || "";
3843
+ lines.push(`**${s.name}** (ID: ${s.id})`);
3844
+ lines.push(`- Type: ${typeLabel}`);
3845
+ if (s.url) lines.push(`- URL: ${s.url}`);
3846
+ if (s.searchQuery) lines.push(`- Search: "${s.searchQuery}"`);
3847
+ if (s.tags && s.tags.length > 0)
3848
+ lines.push(`- Tags: ${s.tags.join(", ")}`);
3849
+ if (s.notes) lines.push(`- Notes: ${s.notes}`);
3850
+ if (s.lastCheckedAt)
3851
+ lines.push(`- Last checked: ${s.lastCheckedAt}`);
3852
+ if (s.lastCheckedSummary)
3853
+ lines.push(`- Last result: ${s.lastCheckedSummary}`);
3854
+ if (hint) lines.push(`- How to check: ${hint}`);
3855
+ lines.push("");
3856
+ }
3857
+ }
3858
+ return {
3859
+ content: [{ type: "text", text: lines.join("\n") }]
3860
+ };
3861
+ }
3862
+ );
3863
+ server.tool(
3864
+ "add_source",
3865
+ "Save a new content source to the customer's profile. Use this when the customer says 'add this to my sources', 'save this feed', 'monitor this blog', 'track this competitor', etc. For RSS feeds, set type to 'feed'. For regular websites/blogs without RSS, set type to 'website'. For YouTube channels, set type to 'youtube'. For search topics, set type to 'search' and provide searchQuery instead of url.",
3866
+ {
3867
+ name: z15.string().describe("Display name for the source"),
3868
+ url: z15.string().optional().describe(
3869
+ "URL of the source (RSS feed, website, YouTube channel, subreddit, or social profile URL). Not needed for 'search' type."
3870
+ ),
3871
+ type: z15.enum([
3872
+ "feed",
3873
+ "website",
3874
+ "youtube",
3875
+ "search",
3876
+ "podcast",
3877
+ "reddit",
3878
+ "social"
3879
+ ]).describe("Type of source. Determines how to fetch content from it."),
3880
+ category: z15.string().optional().describe(
3881
+ "Optional category for organization (e.g. 'competitors', 'industry', 'inspiration', 'my-content')"
3882
+ ),
3883
+ tags: z15.array(z15.string()).optional().describe("Optional tags for filtering"),
3884
+ notes: z15.string().optional().describe("Optional notes about why this source matters"),
3885
+ searchQuery: z15.string().optional().describe(
3886
+ "For 'search' type only: the keyword or topic to search for"
3887
+ )
3888
+ },
3889
+ {
3890
+ title: "Add Content Source",
3891
+ readOnlyHint: false,
3892
+ destructiveHint: false,
3893
+ idempotentHint: false,
3894
+ openWorldHint: false
3895
+ },
3896
+ async (args) => {
3897
+ const data = {
3898
+ name: args.name,
3899
+ type: args.type
3900
+ };
3901
+ if (args.url) data.url = args.url;
3902
+ if (args.category) data.category = args.category;
3903
+ if (args.tags) data.tags = args.tags;
3904
+ if (args.notes) data.notes = args.notes;
3905
+ if (args.searchQuery) data.searchQuery = args.searchQuery;
3906
+ const result = await client.createSource(data);
3907
+ const typeLabel = TYPE_LABELS[result.type] || result.type;
3908
+ const lines = [
3909
+ `## Source Added
3910
+ `,
3911
+ `**${result.name}** (ID: ${result.id})`,
3912
+ `- Type: ${typeLabel}`
3913
+ ];
3914
+ if (result.url) lines.push(`- URL: ${result.url}`);
3915
+ if (result.searchQuery)
3916
+ lines.push(`- Search: "${result.searchQuery}"`);
3917
+ if (result.category) lines.push(`- Category: ${result.category}`);
3918
+ return {
3919
+ content: [{ type: "text", text: lines.join("\n") }]
3920
+ };
3921
+ }
3922
+ );
3923
+ server.tool(
3924
+ "update_source",
3925
+ "Update a saved content source. Use when the customer wants to rename, recategorize, change the URL, add notes, or deactivate a source.",
3926
+ {
3927
+ source_id: z15.number().describe("The ID of the source to update"),
3928
+ name: z15.string().optional().describe("New display name"),
3929
+ url: z15.string().optional().describe("New URL"),
3930
+ type: z15.enum([
3931
+ "feed",
3932
+ "website",
3933
+ "youtube",
3934
+ "search",
3935
+ "podcast",
3936
+ "reddit",
3937
+ "social"
3938
+ ]).optional().describe("New source type"),
3939
+ category: z15.string().optional().describe("New category"),
3940
+ tags: z15.array(z15.string()).optional().describe("New tags"),
3941
+ notes: z15.string().optional().describe("New notes"),
3942
+ searchQuery: z15.string().optional().describe("New search query"),
3943
+ isActive: z15.boolean().optional().describe("Set to false to deactivate")
3944
+ },
3945
+ {
3946
+ title: "Update Content Source",
3947
+ readOnlyHint: false,
3948
+ destructiveHint: false,
3949
+ idempotentHint: true,
3950
+ openWorldHint: false
3951
+ },
3952
+ async (args) => {
3953
+ const { source_id, ...updates } = args;
3954
+ const data = {};
3955
+ for (const [k, v] of Object.entries(updates)) {
3956
+ if (v !== void 0) data[k] = v;
3957
+ }
3958
+ const result = await client.updateSource(source_id, data);
3959
+ return {
3960
+ content: [
3961
+ {
3962
+ type: "text",
3963
+ text: `Source **${result.name}** (ID: ${result.id}) updated successfully.`
3964
+ }
3965
+ ]
3966
+ };
3967
+ }
3968
+ );
3969
+ server.tool(
3970
+ "remove_source",
3971
+ `Remove a content source from the customer's saved sources. Use when the customer says 'remove that source', 'stop tracking X', 'delete that feed', etc.
3972
+
3973
+ REQUIRED WORKFLOW:
3974
+ 1. ALWAYS set confirmed=false first to preview which source will be deleted
3975
+ 2. Show the user the source details before confirming
3976
+ 3. Only confirm after explicit user approval`,
3977
+ {
3978
+ source_id: z15.number().describe("The ID of the source to remove"),
3979
+ confirmed: z15.boolean().default(false).describe("Set to true to confirm deletion. If false or missing, returns a preview for user approval.")
3980
+ },
3981
+ {
3982
+ title: "Remove Content Source",
3983
+ readOnlyHint: false,
3984
+ destructiveHint: true,
3985
+ idempotentHint: false,
3986
+ openWorldHint: false
3987
+ },
3988
+ async (args) => {
3989
+ if (args.confirmed !== true) {
3990
+ const preview = `## Remove Source Confirmation
3991
+
3992
+ **Source ID:** ${args.source_id}
3993
+
3994
+ This will permanently remove this content source. Call this tool again with confirmed=true to proceed.`;
3995
+ return { content: [{ type: "text", text: preview }] };
3996
+ }
3997
+ await client.deleteSource(args.source_id);
3998
+ return {
3999
+ content: [
4000
+ {
4001
+ type: "text",
4002
+ text: "Content source removed successfully."
4003
+ }
4004
+ ]
4005
+ };
4006
+ }
4007
+ );
4008
+ }
4009
+
4010
+ // src/tools/carousel.ts
4011
+ import { z as z16 } from "zod";
4012
+ function registerCarouselTools(server, client) {
4013
+ server.tool(
4014
+ "get_carousel_template",
4015
+ "Get the customer's carousel template configuration. Returns brand colors, logo, CTA text, and other visual settings used to generate carousels from article URLs.",
4016
+ {},
4017
+ {
4018
+ title: "Get Carousel Template",
4019
+ readOnlyHint: true,
4020
+ destructiveHint: false,
4021
+ idempotentHint: true,
4022
+ openWorldHint: false
4023
+ },
4024
+ async () => {
4025
+ try {
4026
+ const template = await client.getCarouselTemplate();
4027
+ const lines = [];
4028
+ lines.push(`## Carousel Template: ${template.name || "Not configured"}`);
4029
+ lines.push("");
4030
+ if (!template.id) {
4031
+ lines.push("No carousel template has been configured yet.");
4032
+ lines.push("");
4033
+ lines.push("Use `save_carousel_template` to set one up with your brand name, colors, and CTA text.");
4034
+ return {
4035
+ content: [{ type: "text", text: lines.join("\n") }]
4036
+ };
4037
+ }
4038
+ lines.push(`**Brand Name:** ${template.brandName || "\u2014"}`);
4039
+ if (template.logoUrl) lines.push(`**Logo:** ${template.logoUrl}`);
4040
+ lines.push(`**Primary Color:** ${template.colorPrimary || "#1a1a2e"}`);
4041
+ lines.push(`**Secondary Color:** ${template.colorSecondary || "#e94560"}`);
4042
+ lines.push(`**Accent Color:** ${template.colorAccent || "#ffffff"}`);
4043
+ lines.push(`**CTA Text:** ${template.ctaText || "Read the full story \u2192"}`);
4044
+ lines.push(`**Logo Placement:** ${template.logoPlacement || "top-left"}`);
4045
+ lines.push("");
4046
+ return {
4047
+ content: [{ type: "text", text: lines.join("\n") }]
4048
+ };
4049
+ } catch (error) {
4050
+ const message = error instanceof Error ? error.message : "Unknown error";
4051
+ if (message.includes("404")) {
4052
+ return {
4053
+ content: [
4054
+ {
4055
+ type: "text",
4056
+ text: "No carousel template has been configured yet. Use `save_carousel_template` to set one up."
4057
+ }
4058
+ ]
4059
+ };
4060
+ }
4061
+ throw error;
4062
+ }
4063
+ }
4064
+ );
4065
+ server.tool(
4066
+ "save_carousel_template",
4067
+ `Create or update the customer's carousel template. This configures the visual style for auto-generated carousels (brand colors, logo, CTA text).
4068
+
4069
+ USAGE:
4070
+ - Set confirmed=false first to preview, then confirmed=true after user approval
4071
+ - brand_name is required
4072
+ - All color values should be hex codes (e.g. "#1a1a2e")
4073
+ - logo_placement: "top-left", "top-right", or "top-center"`,
4074
+ {
4075
+ brand_name: z16.string().max(255).describe("Brand name displayed on CTA slide"),
4076
+ color_primary: z16.string().max(20).optional().describe('Primary background color (hex, e.g. "#1a1a2e")'),
4077
+ color_secondary: z16.string().max(20).optional().describe('Secondary/accent color for buttons and highlights (hex, e.g. "#e94560")'),
4078
+ color_accent: z16.string().max(20).optional().describe('Text color (hex, e.g. "#ffffff")'),
4079
+ cta_text: z16.string().max(255).optional().describe('Call-to-action text on the final slide (e.g. "Read the full story \u2192")'),
4080
+ logo_placement: z16.enum(["top-left", "top-right", "top-center"]).optional().describe("Where to place the logo on slides"),
4081
+ confirmed: z16.boolean().default(false).describe("Set to true to confirm and save. If false, returns a preview.")
4082
+ },
4083
+ {
4084
+ title: "Save Carousel Template",
4085
+ readOnlyHint: false,
4086
+ destructiveHint: false,
4087
+ idempotentHint: false,
4088
+ openWorldHint: false
4089
+ },
4090
+ async (args) => {
4091
+ const payload = {
4092
+ brandName: args.brand_name
4093
+ };
4094
+ if (args.color_primary !== void 0) payload.colorPrimary = args.color_primary;
4095
+ if (args.color_secondary !== void 0) payload.colorSecondary = args.color_secondary;
4096
+ if (args.color_accent !== void 0) payload.colorAccent = args.color_accent;
4097
+ if (args.cta_text !== void 0) payload.ctaText = args.cta_text;
4098
+ if (args.logo_placement !== void 0) payload.logoPlacement = args.logo_placement;
4099
+ if (args.confirmed !== true) {
4100
+ const lines2 = [];
4101
+ lines2.push("## Carousel Template Preview");
4102
+ lines2.push("");
4103
+ lines2.push(`**Brand Name:** ${args.brand_name}`);
4104
+ if (args.color_primary) lines2.push(`**Primary Color:** ${args.color_primary}`);
4105
+ if (args.color_secondary) lines2.push(`**Secondary Color:** ${args.color_secondary}`);
4106
+ if (args.color_accent) lines2.push(`**Accent Color:** ${args.color_accent}`);
4107
+ if (args.cta_text) lines2.push(`**CTA Text:** ${args.cta_text}`);
4108
+ if (args.logo_placement) lines2.push(`**Logo Placement:** ${args.logo_placement}`);
4109
+ lines2.push("");
4110
+ lines2.push("---");
4111
+ lines2.push("Call this tool again with **confirmed=true** to save these settings.");
4112
+ return {
4113
+ content: [{ type: "text", text: lines2.join("\n") }]
4114
+ };
4115
+ }
4116
+ const result = await client.updateCarouselTemplate(payload);
4117
+ const lines = [];
4118
+ lines.push(`Carousel template "${result.brandName || args.brand_name}" has been saved successfully.`);
4119
+ lines.push("");
4120
+ const updated = ["brand name"];
4121
+ if (args.color_primary) updated.push("primary color");
4122
+ if (args.color_secondary) updated.push("secondary color");
4123
+ if (args.color_accent) updated.push("accent color");
4124
+ if (args.cta_text) updated.push("CTA text");
4125
+ if (args.logo_placement) updated.push("logo placement");
4126
+ lines.push(`**Updated:** ${updated.join(", ")}`);
4127
+ return {
4128
+ content: [{ type: "text", text: lines.join("\n") }]
4129
+ };
4130
+ }
4131
+ );
4132
+ server.tool(
4133
+ "generate_carousel",
4134
+ "Generate a full multi-slide carousel from an article URL. Extracts headline and key points using AI, renders branded slides (hero + key points + CTA), and uploads them to CDN. Returns an array of CDN URLs for the rendered slides. Requires a carousel template to be configured first via save_carousel_template.",
4135
+ {
4136
+ url: z16.string().url().describe("The article URL to generate a carousel from")
4137
+ },
4138
+ {
4139
+ title: "Generate Carousel",
4140
+ readOnlyHint: false,
4141
+ destructiveHint: false,
4142
+ idempotentHint: false,
4143
+ openWorldHint: true
4144
+ },
4145
+ async (args) => {
4146
+ try {
4147
+ const result = await client.generateCarousel({
4148
+ url: args.url
4149
+ });
4150
+ const lines = [];
4151
+ lines.push("## Carousel Generated");
4152
+ lines.push("");
4153
+ lines.push(`**Headline:** ${result.article.headline}`);
4154
+ lines.push(`**Source:** ${result.article.sourceDomain}`);
4155
+ lines.push(`**Slides:** ${result.slides.length}`);
4156
+ lines.push("");
4157
+ lines.push("### Key Points");
4158
+ for (const point of result.article.keyPoints) {
4159
+ lines.push(`- ${point}`);
4160
+ }
4161
+ lines.push("");
4162
+ lines.push("### Slide URLs");
4163
+ for (const slide of result.slides) {
4164
+ lines.push(`${slide.slideNumber}. [${slide.type}](${slide.cdnUrl})`);
4165
+ }
4166
+ lines.push("");
4167
+ lines.push("These URLs can be used as `media_urls` when creating a post.");
4168
+ return {
4169
+ content: [{ type: "text", text: lines.join("\n") }]
4170
+ };
4171
+ } catch (error) {
4172
+ const message = error instanceof Error ? error.message : "Unknown error";
4173
+ return {
4174
+ content: [
4175
+ {
4176
+ type: "text",
4177
+ text: `Failed to generate carousel: ${message}`
4178
+ }
4179
+ ],
4180
+ isError: true
4181
+ };
4182
+ }
4183
+ }
4184
+ );
4185
+ server.tool(
4186
+ "preview_carousel",
4187
+ "Generate a quick preview of the carousel hero slide from an article URL. Useful for checking how the template looks before generating the full carousel. Requires a carousel template to be configured first.",
4188
+ {
4189
+ url: z16.string().url().describe("The article URL to preview")
4190
+ },
4191
+ {
4192
+ title: "Preview Carousel",
4193
+ readOnlyHint: true,
4194
+ destructiveHint: false,
4195
+ idempotentHint: false,
4196
+ openWorldHint: true
4197
+ },
4198
+ async (args) => {
4199
+ try {
4200
+ const result = await client.previewCarousel({
4201
+ url: args.url
4202
+ });
4203
+ const lines = [];
4204
+ lines.push("## Carousel Preview");
4205
+ lines.push("");
4206
+ lines.push(`**Headline:** ${result.article.headline}`);
4207
+ lines.push(`**Source:** ${result.article.sourceDomain}`);
4208
+ lines.push("");
4209
+ lines.push(`**Hero Slide:** ${result.cdnUrl}`);
4210
+ lines.push("");
4211
+ lines.push("Use `generate_carousel` to create the full carousel with all slides.");
4212
+ return {
4213
+ content: [{ type: "text", text: lines.join("\n") }]
4214
+ };
4215
+ } catch (error) {
4216
+ const message = error instanceof Error ? error.message : "Unknown error";
4217
+ return {
4218
+ content: [
4219
+ {
4220
+ type: "text",
4221
+ text: `Failed to preview carousel: ${message}`
4222
+ }
4223
+ ],
4224
+ isError: true
4225
+ };
4226
+ }
4227
+ }
4228
+ );
4229
+ }
4230
+ export {
4231
+ BuzzPosterClient,
4232
+ registerAccountInfoTool,
4233
+ registerAccountTools,
4234
+ registerAnalyticsTools,
4235
+ registerAudienceTools,
4236
+ registerAuditLogTools,
4237
+ registerBrandVoiceTools,
4238
+ registerCalendarTools,
4239
+ registerCarouselTools,
4240
+ registerInboxTools,
4241
+ registerKnowledgeTools,
4242
+ registerMediaTools,
4243
+ registerNewsletterAdvancedTools,
4244
+ registerNewsletterTemplateTools,
4245
+ registerNewsletterTools,
4246
+ registerNotificationTools,
4247
+ registerPostTools,
4248
+ registerPublishingRulesTools,
4249
+ registerRssTools,
4250
+ registerSourceTools
4251
+ };