@sendinel/mcp-server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +113 -0
  2. package/build/anthropic-model.d.ts +5 -0
  3. package/build/anthropic-model.js +6 -0
  4. package/build/audit.d.ts +21 -0
  5. package/build/audit.js +38 -0
  6. package/build/auth.d.ts +10 -0
  7. package/build/auth.js +57 -0
  8. package/build/byod-client.d.ts +30 -0
  9. package/build/byod-client.js +95 -0
  10. package/build/crypto.d.ts +2 -0
  11. package/build/crypto.js +27 -0
  12. package/build/db.d.ts +38 -0
  13. package/build/db.js +70 -0
  14. package/build/index.d.ts +1 -0
  15. package/build/index.js +97 -0
  16. package/build/lib/action-approvals.d.ts +30 -0
  17. package/build/lib/action-approvals.js +154 -0
  18. package/build/lib/schedule.d.ts +36 -0
  19. package/build/lib/schedule.js +63 -0
  20. package/build/lib/social-engine/archetypes.d.ts +27 -0
  21. package/build/lib/social-engine/archetypes.js +160 -0
  22. package/build/lib/social-engine/index.d.ts +5 -0
  23. package/build/lib/social-engine/index.js +6 -0
  24. package/build/lib/social-engine/parse.d.ts +7 -0
  25. package/build/lib/social-engine/parse.js +83 -0
  26. package/build/lib/social-engine/platform-constraints.d.ts +27 -0
  27. package/build/lib/social-engine/platform-constraints.js +70 -0
  28. package/build/lib/social-engine/prompt-builder.d.ts +13 -0
  29. package/build/lib/social-engine/prompt-builder.js +80 -0
  30. package/build/lib/social-engine/types.d.ts +60 -0
  31. package/build/lib/social-engine/types.js +19 -0
  32. package/build/lib/url-validation.d.ts +3 -0
  33. package/build/lib/url-validation.js +51 -0
  34. package/build/lib/voice.d.ts +32 -0
  35. package/build/lib/voice.js +140 -0
  36. package/build/lib/webhook-events.d.ts +15 -0
  37. package/build/lib/webhook-events.js +120 -0
  38. package/build/plan-limits.d.ts +8 -0
  39. package/build/plan-limits.js +9 -0
  40. package/build/project.d.ts +1 -0
  41. package/build/project.js +9 -0
  42. package/build/server.d.ts +18 -0
  43. package/build/server.js +235 -0
  44. package/build/tools/ab-testing.d.ts +2 -0
  45. package/build/tools/ab-testing.js +204 -0
  46. package/build/tools/advisor.d.ts +23 -0
  47. package/build/tools/advisor.js +762 -0
  48. package/build/tools/analytics.d.ts +33 -0
  49. package/build/tools/analytics.js +1105 -0
  50. package/build/tools/approvals.d.ts +2 -0
  51. package/build/tools/approvals.js +32 -0
  52. package/build/tools/automations.d.ts +2 -0
  53. package/build/tools/automations.js +344 -0
  54. package/build/tools/campaigns.d.ts +2 -0
  55. package/build/tools/campaigns.js +1335 -0
  56. package/build/tools/compound.d.ts +2 -0
  57. package/build/tools/compound.js +312 -0
  58. package/build/tools/contacts.d.ts +2 -0
  59. package/build/tools/contacts.js +1483 -0
  60. package/build/tools/content.d.ts +2 -0
  61. package/build/tools/content.js +68 -0
  62. package/build/tools/data-proposals.d.ts +2 -0
  63. package/build/tools/data-proposals.js +155 -0
  64. package/build/tools/data.d.ts +2 -0
  65. package/build/tools/data.js +707 -0
  66. package/build/tools/delivery-ops.d.ts +2 -0
  67. package/build/tools/delivery-ops.js +387 -0
  68. package/build/tools/drafts.d.ts +2 -0
  69. package/build/tools/drafts.js +204 -0
  70. package/build/tools/forms.d.ts +2 -0
  71. package/build/tools/forms.js +46 -0
  72. package/build/tools/gdpr.d.ts +2 -0
  73. package/build/tools/gdpr.js +61 -0
  74. package/build/tools/org.d.ts +2 -0
  75. package/build/tools/org.js +71 -0
  76. package/build/tools/segments.d.ts +2 -0
  77. package/build/tools/segments.js +384 -0
  78. package/build/tools/sites.d.ts +2 -0
  79. package/build/tools/sites.js +182 -0
  80. package/build/tools/sms.d.ts +2 -0
  81. package/build/tools/sms.js +489 -0
  82. package/build/tools/social-posts.d.ts +2 -0
  83. package/build/tools/social-posts.js +380 -0
  84. package/build/tools/templates.d.ts +2 -0
  85. package/build/tools/templates.js +282 -0
  86. package/build/tools/warmup.d.ts +2 -0
  87. package/build/tools/warmup.js +57 -0
  88. package/build/tools/webhooks.d.ts +2 -0
  89. package/build/tools/webhooks.js +127 -0
  90. package/package.json +63 -0
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerApprovalTools(server: McpServer): void;
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+ import { db } from '../db.js';
3
+ import { getProjectId } from '../project.js';
4
+ import { checkApproval } from '../lib/action-approvals.js';
5
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
6
+ const j = (data) => txt(JSON.stringify(data, null, 2));
7
+ export function registerApprovalTools(server) {
8
+ server.registerTool('check_approval', {
9
+ description: 'Check the status of a pending action approval. Use this to poll after requesting an action that requires human approval. Returns approved/pending/rejected/expired.',
10
+ inputSchema: {
11
+ approval_id: z.string().uuid().describe('The approval ID returned by the original tool call'),
12
+ },
13
+ }, async ({ approval_id }) => {
14
+ const result = await checkApproval(approval_id);
15
+ return j(result);
16
+ });
17
+ server.registerTool('list_pending_approvals', {
18
+ description: 'List all pending approval requests for this project. Shows what actions are waiting for human approval.',
19
+ inputSchema: {},
20
+ }, async () => {
21
+ const { data, error } = await db()
22
+ .from('action_approvals')
23
+ .select('id, action, tier, summary, status, requested_at, expires_at')
24
+ .eq('project_id', getProjectId())
25
+ .eq('status', 'pending')
26
+ .order('requested_at', { ascending: false })
27
+ .limit(25);
28
+ if (error)
29
+ return txt(`Error: ${error.message}`);
30
+ return j({ pending: data ?? [], count: data?.length ?? 0 });
31
+ });
32
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerAutomationTools(server: McpServer): void;
@@ -0,0 +1,344 @@
1
+ import { z } from 'zod';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+ import { db } from '../db.js';
4
+ import { getProjectId } from '../project.js';
5
+ import { decrypt } from '../crypto.js';
6
+ import { buildVoicePrompt, buildEmailSystemPrompt } from '../lib/voice.js';
7
+ import { getAnthropicModelId } from '../anthropic-model.js';
8
+ const txt = (text) => ({ content: [{ type: 'text', text }] });
9
+ const j = (data) => txt(JSON.stringify(data, null, 2));
10
+ export function registerAutomationTools(server) {
11
+ // ── list_automations ──────────────────────────────────────────────────────
12
+ server.registerTool('list_automations', {
13
+ description: 'List content automation sources (RSS feeds, GitHub Issues, webhooks, schedules). Each source auto-generates email drafts when new content is found.',
14
+ inputSchema: {
15
+ site_id: z.string().uuid().optional().describe('Filter by site UUID'),
16
+ active_only: z.boolean().optional().default(true),
17
+ },
18
+ }, async ({ site_id, active_only }) => {
19
+ let q = db()
20
+ .from('content_sources')
21
+ .select('id, name, source_type, format, feed_url, schedule_cron, github_repo, github_label_filter, active, last_polled_at, items_generated, created_at, site_id')
22
+ .eq('project_id', getProjectId())
23
+ .order('created_at', { ascending: false });
24
+ if (site_id)
25
+ q = q.eq('site_id', site_id);
26
+ if (active_only)
27
+ q = q.eq('active', true);
28
+ const { data, error } = await q;
29
+ if (error)
30
+ return txt(`Error: ${error.message}`);
31
+ return j(data ?? []);
32
+ });
33
+ server.registerTool('enroll_automation', {
34
+ description: 'Enroll a subscriber into active triggered automation campaigns. Match by campaign_id, trigger_event_name, or trigger_tag. Safe to call repeatedly.',
35
+ inputSchema: {
36
+ subscriber_id: z.string().uuid().describe('Subscriber/contact UUID to enroll'),
37
+ campaign_id: z.string().uuid().optional().describe('Specific triggered campaign UUID'),
38
+ site_id: z.string().uuid().optional().describe('Optional site scope when matching by trigger'),
39
+ trigger_event_name: z.string().optional().describe('Event trigger name, e.g. signup'),
40
+ trigger_tag: z.string().optional().describe('Tag trigger name'),
41
+ next_send_at: z.string().datetime().optional().describe('First send time. Defaults to now.'),
42
+ context: z.record(z.string(), z.unknown()).optional().describe('Optional enrollment context metadata'),
43
+ },
44
+ }, async ({ subscriber_id, campaign_id, site_id, trigger_event_name, trigger_tag, next_send_at, context }) => {
45
+ const projectId = getProjectId();
46
+ const { data: subscriber, error: subscriberError } = await db()
47
+ .from('subscribers')
48
+ .select('id, email, global_unsubscribed')
49
+ .eq('id', subscriber_id)
50
+ .eq('project_id', projectId)
51
+ .maybeSingle();
52
+ if (subscriberError)
53
+ return txt(`Error: ${subscriberError.message}`);
54
+ if (!subscriber)
55
+ return j({ error: 'Subscriber not found', code: 'NOT_FOUND' });
56
+ if (subscriber.global_unsubscribed)
57
+ return j({ enrolled: 0, skipped: 'global_unsubscribed' });
58
+ const { data: suppression } = await db()
59
+ .from('suppressions')
60
+ .select('id')
61
+ .eq('project_id', projectId)
62
+ .eq('email', subscriber.email)
63
+ .maybeSingle();
64
+ if (suppression)
65
+ return j({ enrolled: 0, skipped: 'suppressed' });
66
+ let query = db()
67
+ .from('campaigns')
68
+ .select('id, name, site_id, trigger_event_name, trigger_tag')
69
+ .eq('project_id', projectId)
70
+ .eq('type', 'triggered')
71
+ .eq('status', 'active');
72
+ if (campaign_id)
73
+ query = query.eq('id', campaign_id);
74
+ if (site_id)
75
+ query = query.eq('site_id', site_id);
76
+ if (trigger_event_name)
77
+ query = query.eq('trigger_event_name', trigger_event_name.trim());
78
+ if (trigger_tag)
79
+ query = query.eq('trigger_tag', trigger_tag.trim().toLowerCase());
80
+ if (!campaign_id && !trigger_event_name && !trigger_tag) {
81
+ return txt('Provide campaign_id, trigger_event_name, or trigger_tag.');
82
+ }
83
+ const { data: campaigns, error: campaignError } = await query;
84
+ if (campaignError)
85
+ return txt(`Error: ${campaignError.message}`);
86
+ if (!campaigns?.length)
87
+ return j({ enrolled: 0, campaigns: [], skipped: 'no_matching_active_automation' });
88
+ const sendAt = next_send_at ?? new Date().toISOString();
89
+ const rows = campaigns.map((campaign) => ({
90
+ campaign_id: campaign.id,
91
+ contact_id: subscriber_id,
92
+ current_step: 0,
93
+ status: 'active',
94
+ enrolled_at: new Date().toISOString(),
95
+ next_send_at: sendAt,
96
+ enrollment_context: context ?? {},
97
+ }));
98
+ const { data: inserted, error: enrollError } = await db()
99
+ .from('campaign_enrollments')
100
+ .upsert(rows, { onConflict: 'campaign_id,contact_id', ignoreDuplicates: true })
101
+ .select('id, campaign_id');
102
+ if (enrollError)
103
+ return txt(`Error: ${enrollError.message}`);
104
+ if (inserted?.length) {
105
+ for (const campaign of campaigns) {
106
+ const count = inserted.filter((row) => row.campaign_id === campaign.id).length;
107
+ if (count > 0) {
108
+ const { data: current } = await db().from('campaigns').select('total_enrolled').eq('id', campaign.id).maybeSingle();
109
+ await db().from('campaigns').update({ total_enrolled: (current?.total_enrolled ?? 0) + count }).eq('id', campaign.id);
110
+ }
111
+ }
112
+ }
113
+ return j({
114
+ enrolled: inserted?.length ?? 0,
115
+ subscriber_id,
116
+ campaigns: campaigns.map((campaign) => ({
117
+ id: campaign.id,
118
+ name: campaign.name,
119
+ matched: Boolean(inserted?.some((row) => row.campaign_id === campaign.id)),
120
+ })),
121
+ });
122
+ });
123
+ // ── preview_automation ────────────────────────────────────────────────────
124
+ server.registerTool('preview_automation', {
125
+ description: 'Preview what content would be fetched from a source right now, without generating a draft. Use this to inspect GitHub issues, RSS items, etc. before triggering a full run.',
126
+ inputSchema: {
127
+ source_id: z.string().uuid().describe('The content source UUID to preview'),
128
+ },
129
+ }, async ({ source_id }) => {
130
+ const { data: source, error } = await db()
131
+ .from('content_sources')
132
+ .select('*')
133
+ .eq('id', source_id)
134
+ .eq('project_id', getProjectId())
135
+ .maybeSingle();
136
+ if (error || !source)
137
+ return txt('Source not found');
138
+ if (source.source_type === 'github') {
139
+ if (!source.github_repo)
140
+ return txt('No github_repo configured on this source');
141
+ let token;
142
+ if (source.github_token_enc) {
143
+ try {
144
+ token = decrypt(source.github_token_enc);
145
+ }
146
+ catch { /* no token */ }
147
+ }
148
+ const since = source.last_polled_at
149
+ ? new Date(source.last_polled_at).toISOString()
150
+ : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
151
+ const params = new URLSearchParams({ state: 'closed', since, per_page: '20', sort: 'updated', direction: 'desc' });
152
+ if (source.github_label_filter)
153
+ params.set('labels', source.github_label_filter);
154
+ const headers = {
155
+ Accept: 'application/vnd.github.v3+json',
156
+ 'User-Agent': 'Sendinel/1.0',
157
+ };
158
+ if (token)
159
+ headers.Authorization = `Bearer ${token}`;
160
+ const res = await fetch(`https://api.github.com/repos/${source.github_repo}/issues?${params.toString()}`, {
161
+ headers,
162
+ signal: AbortSignal.timeout(10000),
163
+ });
164
+ if (!res.ok)
165
+ return txt(`GitHub API error: ${res.status}`);
166
+ const issues = await res.json();
167
+ const realIssues = issues.filter((i) => !i.pull_request);
168
+ return j({
169
+ source_id,
170
+ source_type: 'github',
171
+ repo: source.github_repo,
172
+ since,
173
+ issue_count: realIssues.length,
174
+ issues: realIssues.map((i) => ({
175
+ number: i.number,
176
+ title: i.title,
177
+ labels: i.labels?.map((l) => l.name) ?? [],
178
+ closed_at: i.closed_at,
179
+ url: i.html_url,
180
+ })),
181
+ });
182
+ }
183
+ if (source.source_type === 'rss') {
184
+ return j({
185
+ source_id,
186
+ source_type: 'rss',
187
+ feed_url: source.feed_url,
188
+ last_polled_at: source.last_polled_at,
189
+ note: 'RSS preview not yet implemented — use trigger_automation to run and generate a draft',
190
+ });
191
+ }
192
+ return j({
193
+ source_id,
194
+ source_type: source.source_type,
195
+ last_polled_at: source.last_polled_at,
196
+ items_generated: source.items_generated,
197
+ note: 'Use trigger_automation to run this source on demand',
198
+ });
199
+ });
200
+ // ── trigger_automation ────────────────────────────────────────────────────
201
+ server.registerTool('trigger_automation', {
202
+ description: 'Run a content source on demand right now — fetches new content, generates a certificate-prompted email draft, and adds it to the approval queue. Returns the draft ID. Use preview_automation first to confirm what content will be pulled.',
203
+ inputSchema: {
204
+ source_id: z.string().uuid().describe('The content source UUID to trigger'),
205
+ since_days: z.number().int().min(1).max(90).optional().describe('For GitHub sources: how many days back to look for closed issues (default: 7). Overrides last_polled_at for this run.'),
206
+ dry_run: z.boolean().optional().default(false).describe('If true, generates and returns the draft without saving it to the approval queue'),
207
+ },
208
+ }, async ({ source_id, since_days, dry_run }) => {
209
+ const projectId = getProjectId();
210
+ const { data: source, error: srcErr } = await db()
211
+ .from('content_sources')
212
+ .select('*, sites!inner(id, name, from_email, from_name, sending_domain, brand_voice)')
213
+ .eq('id', source_id)
214
+ .eq('project_id', projectId)
215
+ .maybeSingle();
216
+ if (srcErr || !source)
217
+ return txt('Source not found or access denied');
218
+ if (!source.active)
219
+ return txt('Source is paused. Resume it before triggering.');
220
+ const site = source.sites;
221
+ let items = [];
222
+ if (source.source_type === 'github') {
223
+ if (!source.github_repo)
224
+ return txt('No github_repo configured on this source');
225
+ let token;
226
+ if (source.github_token_enc) {
227
+ try {
228
+ token = decrypt(source.github_token_enc);
229
+ }
230
+ catch { /* no token */ }
231
+ }
232
+ const since = since_days
233
+ ? new Date(Date.now() - since_days * 24 * 60 * 60 * 1000).toISOString()
234
+ : source.last_polled_at
235
+ ? new Date(source.last_polled_at).toISOString()
236
+ : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
237
+ const params = new URLSearchParams({ state: 'closed', since, per_page: '20', sort: 'updated', direction: 'desc' });
238
+ if (source.github_label_filter)
239
+ params.set('labels', source.github_label_filter);
240
+ const headers = {
241
+ Accept: 'application/vnd.github.v3+json',
242
+ 'User-Agent': 'Sendinel/1.0',
243
+ };
244
+ if (token)
245
+ headers.Authorization = `Bearer ${token}`;
246
+ const ghRes = await fetch(`https://api.github.com/repos/${source.github_repo}/issues?${params.toString()}`, {
247
+ headers,
248
+ signal: AbortSignal.timeout(10000),
249
+ });
250
+ if (!ghRes.ok)
251
+ return txt(`GitHub API error: ${ghRes.status}`);
252
+ const issues = await ghRes.json();
253
+ const realIssues = issues.filter((i) => !i.pull_request);
254
+ if (!realIssues.length)
255
+ return txt(`No closed issues found in ${source.github_repo} for the requested period.`);
256
+ const sinceLabel = new Date(since).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
257
+ const issueList = realIssues.map((i) => `- ${i.title}${i.labels?.length ? ` [${i.labels.map((l) => l.name).join(', ')}]` : ''}${i.html_url ? ` (${i.html_url})` : ''}`).join('\n');
258
+ items = [{
259
+ title: `${realIssues.length} issue${realIssues.length > 1 ? 's' : ''} closed since ${sinceLabel}`,
260
+ body: `Repository: ${source.github_repo}\n\n${issueList}`,
261
+ url: `https://github.com/${source.github_repo}/issues?q=is%3Aissue+is%3Aclosed`,
262
+ }];
263
+ }
264
+ else {
265
+ return txt(`On-demand triggering for source_type "${source.source_type}" is not yet supported via MCP. Use the cron pipeline or trigger via the dashboard.`);
266
+ }
267
+ if (!items.length)
268
+ return txt('No new content found for this source.');
269
+ const apiKey = process.env.ANTHROPIC_API_KEY;
270
+ if (!apiKey)
271
+ return txt('Error: ANTHROPIC_API_KEY not configured');
272
+ const brandVoice = site?.brand_voice ?? null;
273
+ const brandContext = site
274
+ ? `Brand: ${site.name}${site.from_name ? `, From: ${site.from_name}` : ''}${site.sending_domain ? `, Domain: ${site.sending_domain}` : ''}`
275
+ : 'No brand info available';
276
+ const voicePrompt = buildVoicePrompt(brandVoice);
277
+ const baseSystem = buildEmailSystemPrompt(brandContext, voicePrompt);
278
+ const item = items[0];
279
+ const brief = `Write a product update email summarizing recently closed GitHub issues.
280
+
281
+ Repository: ${source.github_repo}
282
+ ${item.title ? `Period: ${item.title}` : ''}
283
+
284
+ Issues closed:
285
+ ${item.body}
286
+
287
+ Group issues by label where possible (Bugs Fixed, New Features, Improvements).
288
+ Each issue should be one short line with a link to the issue URL.
289
+ End with a CTA to view the full changelog on GitHub.`;
290
+ const anthropic = new Anthropic({ apiKey });
291
+ const response = await anthropic.messages.create({
292
+ model: getAnthropicModelId(),
293
+ max_tokens: 1800,
294
+ system: baseSystem,
295
+ messages: [{ role: 'user', content: brief }],
296
+ });
297
+ const textBlock = response.content.find((b) => b.type === 'text');
298
+ const raw = textBlock && 'text' in textBlock ? textBlock.text : '{}';
299
+ const jsonMatch = raw.match(/\{[\s\S]*\}/);
300
+ if (!jsonMatch)
301
+ return txt('Generation failed - could not parse Claude response');
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(jsonMatch[0]);
305
+ }
306
+ catch {
307
+ return txt('Generation failed - invalid JSON from Claude');
308
+ }
309
+ const htmlBody = parsed.html_body ?? parsed.text_body?.replace(/\n/g, '<br>') ?? '';
310
+ const textBody = parsed.text_body ?? '';
311
+ const subject = parsed.subject ?? `Update from ${source.github_repo}`;
312
+ if (dry_run) {
313
+ return j({ dry_run: true, subject, text_body: textBody, html_body: htmlBody, source_id, source_name: source.name });
314
+ }
315
+ const { data: draft, error: draftErr } = await db()
316
+ .from('ai_drafts')
317
+ .insert({
318
+ project_id: projectId,
319
+ site_id: source.site_id,
320
+ subject,
321
+ body_html: htmlBody,
322
+ body_text: textBody,
323
+ prompt: `[Automation] Source: ${source.name}. ${item.title ?? ''}`,
324
+ model: getAnthropicModelId(),
325
+ })
326
+ .select('id')
327
+ .single();
328
+ if (draftErr)
329
+ return txt(`Failed to save draft: ${draftErr.message}`);
330
+ await db()
331
+ .from('content_sources')
332
+ .update({ last_polled_at: new Date().toISOString(), items_generated: (source.items_generated ?? 0) + 1 })
333
+ .eq('id', source_id);
334
+ return j({
335
+ ok: true,
336
+ draft_id: draft.id,
337
+ subject,
338
+ site_id: source.site_id,
339
+ source_name: source.name,
340
+ items_processed: items.length,
341
+ message: 'Draft created and added to approval queue. Use approve_draft or the AI Drafts page to review.',
342
+ });
343
+ });
344
+ }
@@ -0,0 +1,2 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export declare function registerCampaignTools(server: McpServer): void;