@sendinel/mcp-server 1.0.15 → 1.0.17

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,7 @@ AI-controlled email operations control plane for campaigns, contacts, segments,
12
12
 
13
13
  ## Tool groups
14
14
 
15
- Sendinel currently exposes 34 tool groups and 308 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
15
+ Sendinel currently exposes 34 tool groups and 311 tools (gateway mode adds 3 meta-tools). These counts are checked against the tool registry in CI, so this table matches what ships.
16
16
 
17
17
  | Group | Count | Purpose |
18
18
  |-------|-------|---------|
@@ -23,7 +23,7 @@ Sendinel currently exposes 34 tool groups and 308 tools (gateway mode adds 3 met
23
23
  | `approvals` | 2 | Poll and list risk-tiered action approvals |
24
24
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
25
25
  | `brand_kit` | 5 | Brand Core text import, media library upload/URL ingest, asset-context reads |
26
- | `campaigns` | 36 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
26
+ | `campaigns` | 39 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates |
27
27
  | `cloud-import` | 6 | Cloud storage import |
28
28
  | `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
29
29
  | `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
package/build/server.js CHANGED
@@ -95,7 +95,7 @@ const WRITE_TOOLS = new Set([
95
95
  'create_segment', 'update_segment', 'delete_segment', 'create_segment_nl',
96
96
  'create_list', 'update_list', 'delete_list', 'add_list_members', 'remove_list_members', 'add_list_members_by_filter',
97
97
  'launch_campaign', 'merge_subscribers', 'create_campaign_with_content',
98
- 'send_test_email', 'send_transactional', 'subscribe_webhook', 'update_webhook_subscription',
98
+ 'send_test_email', 'send_transactional', 'send_awaiting_reply', 'subscribe_webhook', 'update_webhook_subscription',
99
99
  'create_ab_test', 'promote_ab_winner', 'optimize_subject_lines',
100
100
  'unsubscribe_subscriber', 'add_suppression', 'remove_suppression',
101
101
  'trigger_automation', 'enroll_automation', 'create_escalation_ladder', 'enroll_in_escalation_ladder',
@@ -1,7 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  import Anthropic from '@anthropic-ai/sdk';
3
- import { NAME_FALLBACKS, renderTemplateString as renderTemplateVars, resolveBrandTokens } from '@sendinel/domain';
4
- import { adminDb, db } from '../db.js';
3
+ import { NAME_FALLBACKS, renderTemplateString as renderTemplateVars, resolveBrandTokens, } from '@sendinel/domain';
4
+ // Subpath import, not the barrel above -- reply-actions.ts uses node:crypto,
5
+ // and MCP-server-side that's fine (this never runs in an Edge Function), but
6
+ // keeping it off the shared barrel avoids re-creating the Edge-Function
7
+ // bundling problem this subpath was introduced to fix. See the comment in
8
+ // packages/domain/src/index.ts.
9
+ import { ensureReplyActionSecretConfigured, mintReplyAction, } from '@sendinel/domain/reply-actions';
10
+ import { adminDb, db, requestContext } from '../db.js';
5
11
  import { getProjectId } from '../project.js';
6
12
  import { checkPlanLimit } from '../plan-limits.js';
7
13
  import { buildVoicePrompt, buildEmailSystemPrompt, deriveTemplateVariables, validateTemplate } from '../lib/voice.js';
@@ -1920,6 +1926,307 @@ Do not include body_html in your response; Sendinel renders body_html from block
1920
1926
  resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
1921
1927
  });
1922
1928
  });
1929
+ function parseReplyWindow(input) {
1930
+ const match = /^(\d{1,2})d$/.exec(input.trim());
1931
+ if (!match)
1932
+ return null;
1933
+ const days = Number(match[1]);
1934
+ if (days < 1 || days > 30)
1935
+ return null;
1936
+ return { days, expiresAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000).toISOString() };
1937
+ }
1938
+ function parseAwaitReplyStatuses(status) {
1939
+ const allowed = ['pending', 'consumed', 'expired', 'invalid'];
1940
+ if (!status)
1941
+ return ['pending'];
1942
+ const requested = status.split(',').map((item) => item.trim()).filter(Boolean);
1943
+ const valid = requested.filter((item) => allowed.includes(item));
1944
+ return valid.length ? valid : ['pending'];
1945
+ }
1946
+ server.registerTool('send_awaiting_reply', {
1947
+ title: 'Send Awaiting Reply',
1948
+ annotations: {
1949
+ readOnlyHint: false,
1950
+ destructiveHint: true,
1951
+ idempotentHint: false,
1952
+ openWorldHint: true,
1953
+ },
1954
+ description: 'Send a transactional email and track whether the recipient replies within a deadline. Mints a single-use Reply-To address with an expiry window, tags it with a correlation ID, and logs reply actions. Customer replies are captured as data only.',
1955
+ inputSchema: {
1956
+ to: z.string().email().describe('Recipient email address'),
1957
+ site_id: z.string().uuid().describe('Site UUID (determines from address and provider)'),
1958
+ subject: z.string().optional().describe('Email subject line. Required unless template_id is supplied and the template has a subject_hint.'),
1959
+ html: z.string().optional().describe('HTML body'),
1960
+ text: z.string().optional().describe('Plain text body'),
1961
+ expect_reply_within: z.string().describe("Reply window, e.g. '7d'. 1-30 days."),
1962
+ correlation_id: z.string().min(1).max(255).describe('Opaque id from the calling app (e.g. a Tractic ticket id). Used by get_reply_status/list_awaiting_replies.'),
1963
+ idempotency_key: z.string().optional().describe('Unique key to prevent duplicate sends'),
1964
+ template_id: z.string().uuid().optional().describe('campaign_templates row to render and send. Mutually exclusive with html.'),
1965
+ template_vars: z.record(z.string(), z.string()).optional().describe('Variable values for template_id rendering. Wins over brand tokens on key collision.'),
1966
+ },
1967
+ }, async ({ to, site_id, subject, html, text, expect_reply_within, correlation_id, idempotency_key, template_id, template_vars }) => {
1968
+ const projectId = getProjectId();
1969
+ if (template_id && html)
1970
+ return txt("Error: Provide either 'template_id' or 'html', not both");
1971
+ if (!template_id && !html && !text)
1972
+ return txt('At least one of html or text is required');
1973
+ if (!template_id && !subject?.trim())
1974
+ return txt('Error: subject is required');
1975
+ const window = parseReplyWindow(expect_reply_within);
1976
+ if (!window)
1977
+ return txt("Error: Invalid expect_reply_within. Expected format like '7d' (1-30 days).");
1978
+ try {
1979
+ ensureReplyActionSecretConfigured();
1980
+ }
1981
+ catch (err) {
1982
+ return txt(`Error: ${err instanceof Error ? err.message : 'Reply action secret is not configured'}`);
1983
+ }
1984
+ // Suppression check
1985
+ const { data: suppression } = await db()
1986
+ .from('suppressions')
1987
+ .select('id, reason')
1988
+ .eq('project_id', projectId)
1989
+ .eq('email', to.toLowerCase())
1990
+ .limit(1)
1991
+ .maybeSingle();
1992
+ if (suppression)
1993
+ return txt(`Error: Recipient suppressed — ${suppression.reason}`);
1994
+ // Idempotency check
1995
+ if (idempotency_key) {
1996
+ const { data: existing } = await db()
1997
+ .from('email_log')
1998
+ .select('id, provider_message_id, status')
1999
+ .eq('project_id', projectId)
2000
+ .eq('idempotency_key', idempotency_key)
2001
+ .maybeSingle();
2002
+ if (existing) {
2003
+ return j({
2004
+ error: 'Duplicate send',
2005
+ id: existing.id,
2006
+ provider_message_id: existing.provider_message_id,
2007
+ resend_id: existing.provider_message_id,
2008
+ });
2009
+ }
2010
+ }
2011
+ // Site verification
2012
+ const { data: site } = await db()
2013
+ .from('sites')
2014
+ .select('from_email, from_name, name, brand_voice')
2015
+ .eq('id', site_id)
2016
+ .eq('project_id', projectId)
2017
+ .maybeSingle();
2018
+ if (!site)
2019
+ return txt('Error: Site not found');
2020
+ let resolvedSubject = subject;
2021
+ let resolvedHtml = html;
2022
+ let resolvedText = text;
2023
+ if (template_id) {
2024
+ const { data: template, error: templateError } = await db()
2025
+ .from('campaign_templates')
2026
+ .select('id, subject_hint, body_html, body_text, format')
2027
+ .eq('id', template_id)
2028
+ .eq('project_id', projectId)
2029
+ .maybeSingle();
2030
+ if (templateError)
2031
+ return txt(`Error: ${templateError.message}`);
2032
+ if (!template)
2033
+ return txt('Error: Template not found');
2034
+ const { data: project } = await adminDb().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
2035
+ const projectBrand = (project?.brand_settings ?? null);
2036
+ const siteBrand = (site.brand_voice ?? null);
2037
+ const mergedBrand = projectBrand || siteBrand ? { ...(projectBrand ?? {}), ...(siteBrand ?? {}) } : null;
2038
+ const brandVars = resolveBrandTokens(mergedBrand, site.name || undefined);
2039
+ const vars = template_vars ?? {};
2040
+ const renderVars = { ...brandVars, ...vars };
2041
+ const subjectFallingBack = !subject?.trim();
2042
+ const requiredVars = deriveTemplateVariables({
2043
+ body_html: template.format === 'text' ? null : (template.body_html ?? ''),
2044
+ body_text: template.format === 'text' ? (template.body_text ?? '') : null,
2045
+ subject_hint: subjectFallingBack ? (template.subject_hint ?? '') : null,
2046
+ });
2047
+ const satisfied = new Set([
2048
+ ...Object.keys(vars),
2049
+ 'brand_name', 'brand_logo_url', 'brand_bg_color', 'brand_text_color', 'brand_accent_color',
2050
+ ...Object.keys(NAME_FALLBACKS),
2051
+ ]);
2052
+ const missing = requiredVars.filter((name) => !satisfied.has(name));
2053
+ if (missing.length) {
2054
+ return txt(`Error: Missing required template variable(s): ${missing.join(', ')}`);
2055
+ }
2056
+ if (template.format === 'text') {
2057
+ resolvedText = renderTemplateVars(template.body_text ?? '', renderVars);
2058
+ resolvedHtml = undefined;
2059
+ }
2060
+ else {
2061
+ resolvedHtml = renderTemplateVars(template.body_html ?? '', renderVars);
2062
+ resolvedText = text ?? stripHtml(resolvedHtml);
2063
+ }
2064
+ resolvedSubject = subject?.trim() || renderTemplateVars(template.subject_hint ?? '', renderVars);
2065
+ }
2066
+ if (!resolvedSubject?.trim())
2067
+ return txt('Error: subject is required');
2068
+ const { data: projectContext, error: projectContextError } = await adminDb()
2069
+ .from('projects')
2070
+ .select('org_id, owner_user_id')
2071
+ .eq('id', projectId)
2072
+ .maybeSingle();
2073
+ const actorUserId = requestContext()?.actorId ?? projectContext?.owner_user_id ?? null;
2074
+ if (projectContextError || !projectContext?.org_id || !actorUserId) {
2075
+ return txt('Error: Reply action cannot be minted for this project');
2076
+ }
2077
+ // Insert email_log (status queued) BEFORE minting and sending so reply token has source_email_log_id
2078
+ const { data: logRow, error: logError } = await db().from('email_log').insert({
2079
+ project_id: projectId,
2080
+ site_id,
2081
+ contact_id: null,
2082
+ to_email: to,
2083
+ from_email: site.from_email,
2084
+ subject: resolvedSubject,
2085
+ status: 'queued',
2086
+ category: 'transactional',
2087
+ idempotency_key: idempotency_key ?? null,
2088
+ body_html: resolvedHtml ?? null,
2089
+ }).select('id').single();
2090
+ if (logError && idempotency_key && logError.code === '23505') {
2091
+ const { data: existing } = await db()
2092
+ .from('email_log')
2093
+ .select('id, provider_message_id, status')
2094
+ .eq('project_id', projectId)
2095
+ .eq('idempotency_key', idempotency_key)
2096
+ .maybeSingle();
2097
+ if (existing) {
2098
+ return j({
2099
+ error: 'Duplicate send',
2100
+ id: existing.id,
2101
+ provider_message_id: existing.provider_message_id,
2102
+ resend_id: existing.provider_message_id,
2103
+ });
2104
+ }
2105
+ }
2106
+ if (logError || !logRow?.id) {
2107
+ return txt(`Error: ${logError?.message ?? 'Failed to create email log row'}`);
2108
+ }
2109
+ let minted;
2110
+ try {
2111
+ minted = await mintReplyAction({
2112
+ db: adminDb(),
2113
+ orgId: projectContext.org_id,
2114
+ projectId,
2115
+ userId: actorUserId,
2116
+ actionType: 'await_reply',
2117
+ resourceId: logRow.id,
2118
+ sourceEmailLogId: logRow.id,
2119
+ correlationId: correlation_id,
2120
+ expiresAt: window.expiresAt,
2121
+ });
2122
+ }
2123
+ catch (err) {
2124
+ await db().from('email_log').update({ status: 'failed' }).eq('id', logRow.id);
2125
+ const errMsg = err instanceof Error ? err.message : String(err);
2126
+ if (errMsg.includes('23505') || errMsg.includes('uidx_reply_actions_pending_correlation') || errMsg.includes('already pending')) {
2127
+ return txt('An awaiting-reply send for this correlation_id is already pending.');
2128
+ }
2129
+ return txt(`Error: Failed to mint reply action — ${errMsg}`);
2130
+ }
2131
+ const payload = {
2132
+ from: `${site.from_name} <${site.from_email}>`,
2133
+ to,
2134
+ subject: resolvedSubject,
2135
+ html: resolvedHtml,
2136
+ text: resolvedText,
2137
+ replyTo: minted.replyToAddress,
2138
+ };
2139
+ let sendResult;
2140
+ try {
2141
+ sendResult = await sendEmailForSite(projectId, site_id, payload);
2142
+ }
2143
+ catch (error) {
2144
+ await db().from('email_log').update({ status: 'failed' }).eq('id', logRow.id);
2145
+ return txt(`Error: Send failed — ${error instanceof Error ? error.message : 'unknown provider error'}`);
2146
+ }
2147
+ await db().from('email_log').update({
2148
+ status: 'sent',
2149
+ sent_at: new Date().toISOString(),
2150
+ provider_message_id: sendResult.messageId ?? null,
2151
+ }).eq('id', logRow.id);
2152
+ return j({
2153
+ sent: true,
2154
+ id: logRow.id,
2155
+ reply_action_id: minted.id,
2156
+ correlation_id,
2157
+ expires_at: window.expiresAt,
2158
+ to,
2159
+ provider: sendResult.provider,
2160
+ provider_message_id: sendResult.messageId,
2161
+ resend_id: sendResult.provider === 'resend' ? sendResult.messageId : null,
2162
+ });
2163
+ });
2164
+ server.registerTool('get_reply_status', {
2165
+ title: 'Get Reply Status',
2166
+ annotations: {
2167
+ readOnlyHint: true,
2168
+ destructiveHint: false,
2169
+ idempotentHint: true,
2170
+ openWorldHint: false,
2171
+ },
2172
+ description: 'Get the reply status of an awaiting-reply message by its correlation ID.',
2173
+ inputSchema: {
2174
+ correlation_id: z.string().min(1).max(255).describe('Opaque correlation ID from the calling app'),
2175
+ },
2176
+ }, async ({ correlation_id }) => {
2177
+ const { data, error } = await adminDb()
2178
+ .from('reply_actions')
2179
+ .select('id, status, correlation_id, source_email_log_id, reply_text, consumed_from_address, reply_received_at, expires_at, expiry_work_item_id')
2180
+ .eq('project_id', getProjectId())
2181
+ .eq('action_type', 'await_reply')
2182
+ .eq('correlation_id', correlation_id)
2183
+ .order('created_at', { ascending: false })
2184
+ .limit(1)
2185
+ .maybeSingle();
2186
+ if (error)
2187
+ return txt(`Error: ${error.message}`);
2188
+ if (!data)
2189
+ return j({ found: false, correlation_id });
2190
+ const outcome = data.status === 'pending' ? 'awaiting'
2191
+ : data.status === 'consumed' ? 'replied'
2192
+ : data.status === 'expired' ? 'no_reply_by_deadline'
2193
+ : 'unknown';
2194
+ return j({ found: true, correlation_id, outcome, ...data });
2195
+ });
2196
+ server.registerTool('list_awaiting_replies', {
2197
+ title: 'List Awaiting Replies',
2198
+ annotations: {
2199
+ readOnlyHint: true,
2200
+ destructiveHint: false,
2201
+ idempotentHint: true,
2202
+ openWorldHint: false,
2203
+ },
2204
+ description: 'List awaiting-reply messages for the project, filtered by status.',
2205
+ inputSchema: {
2206
+ status: z.string().optional().describe('Comma-separated: pending,consumed,expired. Defaults to pending.'),
2207
+ limit: z.number().int().min(1).max(100).optional().default(25),
2208
+ },
2209
+ }, async ({ status, limit = 25 }) => {
2210
+ const statuses = parseAwaitReplyStatuses(status);
2211
+ const { data, error } = await adminDb()
2212
+ .from('reply_actions')
2213
+ .select('id, status, correlation_id, source_email_log_id, reply_text, consumed_from_address, reply_received_at, expires_at, expiry_work_item_id, created_at')
2214
+ .eq('project_id', getProjectId())
2215
+ .eq('action_type', 'await_reply')
2216
+ .in('status', statuses)
2217
+ .order('created_at', { ascending: false })
2218
+ .limit(limit);
2219
+ if (error)
2220
+ return txt(`Error: ${error.message}`);
2221
+ const items = (data ?? []).map((row) => ({
2222
+ ...row,
2223
+ outcome: row.status === 'pending' ? 'awaiting'
2224
+ : row.status === 'consumed' ? 'replied'
2225
+ : row.status === 'expired' ? 'no_reply_by_deadline'
2226
+ : 'unknown',
2227
+ }));
2228
+ return j({ items, count: items.length });
2229
+ });
1923
2230
  server.registerTool('validate_template', {
1924
2231
  title: 'Validate Template',
1925
2232
  annotations: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
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",