@sendinel/mcp-server 1.0.97 → 1.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,7 +12,13 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
12
12
 
13
13
  ## Tool groups
14
14
 
15
- Sendinel currently exposes 35 tool groups and 339 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
15
+ `emit_contact_event` (contacts, write scope) records an event and can enroll
16
+ contacts into active sending campaigns. It requires standard human approval,
17
+ preserves REST outcomes, and redacts failures. See the
18
+ [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
+ inputs, idempotency, subscription requirements, and conditional step examples.
20
+
21
+ Sendinel currently exposes 35 tool groups and 340 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
16
22
 
17
23
  | Group | Count | Purpose |
18
24
  |-------|-------|---------|
@@ -28,7 +34,7 @@ Sendinel currently exposes 35 tool groups and 339 tools (gateway mode exposes 4
28
34
  | `cloud-import` | 6 | Cloud storage import |
29
35
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
30
36
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
31
- | `contacts` | 38 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
37
+ | `contacts` | 39 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
32
38
  | `content` | 14 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
33
39
  | `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
34
40
  | `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
package/build/db.d.ts CHANGED
@@ -3,6 +3,7 @@ export type V1CallOptions = {
3
3
  method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
4
4
  body?: unknown;
5
5
  query?: Record<string, string>;
6
+ idempotencyKey?: string;
6
7
  };
7
8
  export type V1CallResult = {
8
9
  ok: true;
@@ -54,6 +54,7 @@ const GATED_ACTIONS = new Set([
54
54
  'tenant_export_full', // copies all customer data out of Sendinel
55
55
  'enroll_contact', // starts real emails to a contact
56
56
  'identify_subscriber', // may enroll a new signup in sending campaigns
57
+ 'emit_contact_event', // may enroll a real contact in active sending campaigns
57
58
  'enroll_automation', // starts automated messages to a contact
58
59
  'enroll_in_escalation_ladder', // starts email/SMS escalation to a contact
59
60
  'set_contact_sms_consent', // changes the consent record that allows texting
@@ -28,6 +28,7 @@ export async function callSendinelV1(path, opts = {}) {
28
28
  Authorization: `Bearer ${apiKey}`,
29
29
  'X-Sendinel-Project-Id': getProjectId(),
30
30
  'Content-Type': 'application/json',
31
+ ...(opts.idempotencyKey ? { 'Idempotency-Key': opts.idempotencyKey } : {}),
31
32
  },
32
33
  ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
33
34
  });
package/build/server.js CHANGED
@@ -82,7 +82,7 @@ export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
82
82
  const WRITE_TOOLS = new Set([
83
83
  'recheck_domain_dns',
84
84
  'mark_agent_message', 'draft_agent_reply', 'create_agent_mailbox',
85
- 'add_subscriber', 'identify_subscriber', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
85
+ 'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
86
86
  'create_subscriber_view', 'update_subscriber_view', 'delete_subscriber_view',
87
87
  'enrich_subscriber',
88
88
  'create_campaign', 'update_campaign', 'delete_campaign', 'add_campaign_step', 'update_campaign_status',
@@ -1,7 +1,7 @@
1
1
  import { CAMPAIGN_BRANCH_CONDITIONS, campaignBranchConditionValue, isContactPropertyConditionList, STEP_PROPERTY_CONDITIONS_ERROR, validateStepPropertyRules } from "@sendinel/domain";
2
2
  import { z } from 'zod';
3
3
  import Anthropic from '@anthropic-ai/sdk';
4
- import { assertSendAccepted, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
4
+ import { assertSendAccepted, providerSendError, sendError, SendRefusedError, BLOCKING_CONTENT_ISSUE_KINDS, computeSendContentKey, findContentIssues, sendingHostOf, NAME_FALLBACKS, ONE_SEND_MATCH_STATUSES, ONE_SEND_SENT_STATUSES, ONE_SEND_WINDOW_DAYS, normalizeTriggerConfig, renderTemplateString as renderTemplateVars, resolveBrandTokens, brandWarnings, readAllRows, } from '@sendinel/domain';
5
5
  // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
6
6
  // and MCP-server-side that's fine (this never runs in an Edge Function), but
7
7
  // keeping it off the shared barrel avoids re-creating the Edge-Function
@@ -2331,7 +2331,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
2331
2331
  .limit(1)
2332
2332
  .maybeSingle();
2333
2333
  if (suppression)
2334
- return txt(`Error: Recipient suppressed — ${suppression.reason}`);
2334
+ return { ...txt('Error: Recipient is suppressed'), code: 'invalid_recipient' };
2335
2335
  // Idempotency check
2336
2336
  if (idempotency_key) {
2337
2337
  const { data: existing } = await db()
@@ -2369,8 +2369,10 @@ Do not include body_html in your response; Sendinel renders body_html from block
2369
2369
  .eq('id', template_id)
2370
2370
  .eq('project_id', projectId)
2371
2371
  .maybeSingle();
2372
- if (templateError)
2373
- return txt(`Error: ${templateError.message}`);
2372
+ if (templateError) {
2373
+ console.error('MCP send template lookup failed:', templateError);
2374
+ return { ...txt('Error: Internal server error'), ...sendError('internal_error') };
2375
+ }
2374
2376
  if (!template)
2375
2377
  return txt('Error: Template not found');
2376
2378
  const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
@@ -2425,10 +2427,13 @@ Do not include body_html in your response; Sendinel renders body_html from block
2425
2427
  assertSendAccepted(sendResult);
2426
2428
  }
2427
2429
  catch (error) {
2430
+ console.error('MCP transactional send failed:', error);
2428
2431
  if (error instanceof SendRefusedError) {
2429
2432
  await db().from('email_log').insert({ project_id: projectId, site_id, to_email: to, from_email: site.from_email, subject: resolvedSubject, category: 'transactional', status: 'failed', error_message: error.message });
2430
2433
  }
2431
- return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
2434
+ const safe = providerSendError(error);
2435
+ const code = error instanceof SendRefusedError ? error.refusal.code : safe.code;
2436
+ return { ...txt(`Error: Send failed — ${safe.error} (${code})`), error: safe.error, code };
2432
2437
  }
2433
2438
  // Log to email_log
2434
2439
  const { data: logRow } = await db().from('email_log').insert({
@@ -536,6 +536,41 @@ async function exportSubscriberData(projectId, contactId) {
536
536
  };
537
537
  }
538
538
  export function registerContactTools(server) {
539
+ server.registerTool('emit_contact_event', {
540
+ title: 'Emit Contact Event',
541
+ description: 'Record a contact event through POST /api/v1/events and enroll matching active triggered campaigns. Can start real sends; requires standard approval. Returns the REST outcome and sendability unchanged. Does not subscribe the contact or update contact properties.',
542
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
543
+ inputSchema: {
544
+ approval_id: z.string().uuid().optional().describe('Call once to request approval, then repeat the same arguments with the approved approval_id.'),
545
+ email: z.string().email(),
546
+ event: z.string().trim().min(1),
547
+ data: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
548
+ idempotency_key: z.string().min(1).optional().describe('Sent as Idempotency-Key. Reuse with the same event payload when retrying.'),
549
+ },
550
+ }, withApproval('emit_contact_event', async ({ email, event, data, idempotency_key }) => {
551
+ // Never forward raw transport, database or provider errors to the MCP caller.
552
+ // Only known public codes survive; even a returned message may contain internal text.
553
+ const publicCodes = new Set([
554
+ 'contact_limit', 'invalid_consent_site', 'contact_create_failed', 'event_record_failed', 'event_intake_failed',
555
+ 'IDEMPOTENCY_UNAVAILABLE', 'IDEMPOTENCY_KEY_REUSED', 'IDEMPOTENCY_IN_FLIGHT',
556
+ ]);
557
+ try {
558
+ const result = await callSendinelV1('/api/v1/events', {
559
+ method: 'POST',
560
+ body: { email, event, ...(data !== undefined ? { data } : {}) },
561
+ ...(idempotency_key ? { idempotencyKey: idempotency_key } : {}),
562
+ });
563
+ if (result.ok)
564
+ return j(result.data);
565
+ const payload = result.error && typeof result.error === 'object' && 'error' in result.error
566
+ && result.error.error && typeof result.error.error === 'object' ? result.error.error : result.error;
567
+ const code = payload && typeof payload === 'object' && 'code' in payload ? payload.code : undefined;
568
+ return { ...j({ code: typeof code === 'string' && publicCodes.has(code) ? code : 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
569
+ }
570
+ catch {
571
+ return { ...j({ code: 'event_intake_failed', error: 'Could not emit contact event' }), isError: true };
572
+ }
573
+ }));
539
574
  server.registerTool('identify_subscriber', {
540
575
  title: 'Identify Subscriber',
541
576
  description: 'Identify or update a contact through the v1 identify API. Optional signup/customer consent subscribes new or existing contacts without overriding opt-outs. Customer consent requires a topic; consent requires site_id. Without consent, site_id retains legacy create-only subscription behavior and returns a warning.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.97",
3
+ "version": "1.0.99",
4
4
  "description": "AI-controlled email operations control plane for campaigns, contacts, segments, analytics, and multi-provider sending via MCP.",
5
5
  "mcpName": "ai.sendinel/mcp-server",
6
6
  "type": "module",