@sendinel/mcp-server 1.0.214 → 1.0.215
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 +4 -4
- package/build/lib/approval-actions.js +5 -0
- package/build/lib/survey-link-resolver.js +1 -0
- package/build/server.js +3 -3
- package/build/tools/content.js +42 -0
- package/build/tools/forms.js +107 -16
- package/build/tools/short-links.js +39 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
|
|
|
18
18
|
[event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
|
|
19
19
|
inputs, idempotency, subscription requirements, and conditional step examples.
|
|
20
20
|
|
|
21
|
-
Sendinel currently exposes 35 tool groups and
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 356 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.
|
|
22
22
|
|
|
23
23
|
| Group | Count | Purpose |
|
|
24
24
|
|-------|-------|---------|
|
|
@@ -35,19 +35,19 @@ Sendinel currently exposes 35 tool groups and 352 tools (gateway mode exposes 4
|
|
|
35
35
|
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
36
36
|
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
37
37
|
| `contacts` | 40 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
|
|
38
|
-
| `content` |
|
|
38
|
+
| `content` | 15 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
|
|
39
39
|
| `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
|
|
40
40
|
| `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
|
|
41
41
|
| `delivery-ops` | 25 | Domains, DNSBL, deliverability diagnostics, queue, DLQ, exports, blackout dates, abuse overrides |
|
|
42
42
|
| `drafts` | 6 | Draft create/approve/reject and draft-to-campaign promotion |
|
|
43
|
-
| `forms` |
|
|
43
|
+
| `forms` | 5 | Form creation, listing, stats |
|
|
44
44
|
| `gdpr` | 4 | Contact data deletion and GDPR log |
|
|
45
45
|
| `landing-pages` | 5 | Landing page CRUD |
|
|
46
46
|
| `lists` | 7 | Static contact lists and membership management |
|
|
47
47
|
| `newsletters` | 12 | Newsletter publications, issues, subscribers, scheduling, sending, stats |
|
|
48
48
|
| `org` | 24 | Team members, plan usage/limits, audit log, model registry, white-label, support tickets, outbound digest, provider switching |
|
|
49
49
|
| `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
|
|
50
|
-
| `short-links` |
|
|
50
|
+
| `short-links` | 4 | Short links, click stats, QR codes |
|
|
51
51
|
| `signatures` | 7 | Sending signature blocks |
|
|
52
52
|
| `sites` | 7 | Site, sender, and brand voice management |
|
|
53
53
|
| `skills` | 3 | Operator skills catalog |
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export const GATED_ACTIONS = new Set([
|
|
2
|
+
// #4392: edits and reversible archive/disable transitions affect public visitors.
|
|
3
|
+
'update_form',
|
|
4
|
+
'archive_form',
|
|
5
|
+
'update_short_link',
|
|
6
|
+
'update_asset_link',
|
|
2
7
|
'set_link_domain', // domain routing and optional customer DNS writes, one approval
|
|
3
8
|
'recheck_domain_dns', // live provider verification may request an EE certificate
|
|
4
9
|
'draft_agent_reply',
|
|
@@ -14,6 +14,7 @@ export async function resolveSurveyShortLink(sb, projectId, surveyId) {
|
|
|
14
14
|
.from('short_links')
|
|
15
15
|
.select('id, slug, project_id')
|
|
16
16
|
.eq('id', survey.short_link_id)
|
|
17
|
+
.is('disabled_at', null)
|
|
17
18
|
.eq('project_id', projectId)
|
|
18
19
|
.maybeSingle();
|
|
19
20
|
return link?.slug ? `${appBaseUrl()}/s/${link.slug}` : null;
|
package/build/server.js
CHANGED
|
@@ -120,7 +120,7 @@ const WRITE_TOOLS = new Set([
|
|
|
120
120
|
'trigger_automation', 'enroll_automation', 'create_escalation_ladder', 'enroll_in_escalation_ladder',
|
|
121
121
|
'update_scoring_rules', 'register_domain', 'publish_dmarc_record',
|
|
122
122
|
'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
|
|
123
|
-
'create_form', 'invite_team_member',
|
|
123
|
+
'create_form', 'update_form', 'archive_form', 'invite_team_member',
|
|
124
124
|
'remove_team_member', 'set_team_member_role', 'set_member_project_access',
|
|
125
125
|
'set_active_project',
|
|
126
126
|
'resend_invitation', 'revoke_invitation',
|
|
@@ -129,7 +129,7 @@ const WRITE_TOOLS = new Set([
|
|
|
129
129
|
'create_api_key', 'revoke_api_key', 'mark_notifications_read',
|
|
130
130
|
'migrate_campaigns_from', 'start_platform_migration',
|
|
131
131
|
'create_content_block', 'delete_content_block', 'trigger_content_pipeline',
|
|
132
|
-
'create_asset_link',
|
|
132
|
+
'create_asset_link', 'update_asset_link',
|
|
133
133
|
'set_white_label_settings', 'add_white_label_domain',
|
|
134
134
|
'create_blog_post',
|
|
135
135
|
'generate_blog_post_image',
|
|
@@ -141,7 +141,7 @@ const WRITE_TOOLS = new Set([
|
|
|
141
141
|
'create_custom_webhook', 'rotate_custom_webhook_secret',
|
|
142
142
|
'update_brand_voice', 'promote_draft_to_campaign',
|
|
143
143
|
'propose_campaign_from_data', 'approve_data_proposal',
|
|
144
|
-
'create_survey', 'update_survey', 'create_short_link', 'generate_qr_code',
|
|
144
|
+
'create_survey', 'update_survey', 'create_short_link', 'update_short_link', 'generate_qr_code',
|
|
145
145
|
'create_signature', 'update_signature',
|
|
146
146
|
'mark_ai_work_item_seen', 'snooze_ai_work_item', 'dismiss_ai_work_item',
|
|
147
147
|
'submit_ai_context_event',
|
package/build/tools/content.js
CHANGED
|
@@ -143,6 +143,48 @@ export function registerContentTools(server) {
|
|
|
143
143
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
|
|
144
144
|
return j({ asset_link: { ...data, url: `${appUrl.replace(/\/$/, '')}/a/${data.slug}` } });
|
|
145
145
|
});
|
|
146
|
+
server.registerTool('update_asset_link', {
|
|
147
|
+
title: 'Update Asset Link',
|
|
148
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
149
|
+
description: 'Update a project asset link or reversibly archive/restore it. Keeps its slug and view history. Requires approval in standard mode.',
|
|
150
|
+
inputSchema: {
|
|
151
|
+
asset_link_id: z.string().uuid(),
|
|
152
|
+
title: z.string().min(1).max(255).optional(),
|
|
153
|
+
asset_url: z.string().url().max(2000).optional(),
|
|
154
|
+
gate_headline: z.string().max(255).optional(),
|
|
155
|
+
gate_body: z.string().max(1000).optional(),
|
|
156
|
+
source_attribution: z.string().max(500).optional(),
|
|
157
|
+
status: z.enum(['draft', 'active', 'archived']).optional(),
|
|
158
|
+
approval_id: z.string().uuid().optional(),
|
|
159
|
+
},
|
|
160
|
+
}, withApproval('update_asset_link', async ({ asset_link_id, title, asset_url, gate_headline, gate_body, source_attribution, status }) => {
|
|
161
|
+
for (const [field, value] of Object.entries({ title, source_attribution, gate_headline, gate_body })) {
|
|
162
|
+
const error = refuseActiveTextFields(field, value);
|
|
163
|
+
if (error)
|
|
164
|
+
return j({ error, code: 'validation_failed' });
|
|
165
|
+
}
|
|
166
|
+
if (asset_url !== undefined) {
|
|
167
|
+
try {
|
|
168
|
+
assertHttpUrl(asset_url);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return j({ error: 'asset_url must use http or https', code: 'validation_failed' });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const patch = Object.fromEntries(Object.entries({ title, asset_url, gate_headline, gate_body, source_attribution, status })
|
|
175
|
+
.filter(([, value]) => value !== undefined));
|
|
176
|
+
if (!Object.keys(patch).length)
|
|
177
|
+
return j({ error: 'Provide at least one asset link field to update', code: 'validation_failed' });
|
|
178
|
+
const { data, error } = await db().from('asset_links').update({ ...patch, updated_at: new Date().toISOString() })
|
|
179
|
+
.eq('id', asset_link_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
180
|
+
if (error)
|
|
181
|
+
return j(dbToolError(error));
|
|
182
|
+
if (!data)
|
|
183
|
+
return j({ error: 'Asset link not found', code: 'NOT_FOUND' });
|
|
184
|
+
await logAudit({ action: 'asset_link.updated', resourceType: 'asset_link', resourceId: asset_link_id, details: patch });
|
|
185
|
+
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
186
|
+
return j({ asset_link: { ...data, url: `${appUrl}/a/${data.slug}` } });
|
|
187
|
+
}));
|
|
146
188
|
server.registerTool('list_asset_links', {
|
|
147
189
|
title: 'List Asset Links',
|
|
148
190
|
annotations: {
|
package/build/tools/forms.js
CHANGED
|
@@ -2,8 +2,29 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
5
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
6
|
+
import { logAudit } from '../audit.js';
|
|
5
7
|
import { dbToolError } from '../lib/db-error.js';
|
|
6
8
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
9
|
+
function validateFields(fields) {
|
|
10
|
+
if (!fields.length)
|
|
11
|
+
return 'fields must contain at least one field';
|
|
12
|
+
const names = new Set();
|
|
13
|
+
for (const field of fields) {
|
|
14
|
+
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
15
|
+
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
16
|
+
return 'Each field needs a name (lowercase snake_case) and a supported type';
|
|
17
|
+
}
|
|
18
|
+
if (names.has(field.name))
|
|
19
|
+
return `Duplicate field name: ${field.name}`;
|
|
20
|
+
names.add(field.name);
|
|
21
|
+
}
|
|
22
|
+
// The public handler always reads fields.email to identify the subscriber.
|
|
23
|
+
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
24
|
+
return 'fields must include an email field named email';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
7
28
|
export function registerFormTools(server) {
|
|
8
29
|
server.registerTool('list_forms', {
|
|
9
30
|
title: 'List Forms',
|
|
@@ -70,22 +91,9 @@ export function registerFormTools(server) {
|
|
|
70
91
|
form_type: z.enum(['signup', 'management']).optional(),
|
|
71
92
|
},
|
|
72
93
|
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const field of fields) {
|
|
77
|
-
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
78
|
-
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
79
|
-
return j({ error: 'Each field needs a name (lowercase snake_case) and a supported type', code: 'validation_failed' });
|
|
80
|
-
}
|
|
81
|
-
if (names.has(field.name))
|
|
82
|
-
return j({ error: `Duplicate field name: ${field.name}`, code: 'validation_failed' });
|
|
83
|
-
names.add(field.name);
|
|
84
|
-
}
|
|
85
|
-
// The public handler always reads fields.email to identify the subscriber.
|
|
86
|
-
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
87
|
-
return j({ error: 'fields must include an email field named email', code: 'validation_failed' });
|
|
88
|
-
}
|
|
94
|
+
const fieldsError = validateFields(fields);
|
|
95
|
+
if (fieldsError)
|
|
96
|
+
return j({ error: fieldsError, code: 'validation_failed' });
|
|
89
97
|
if (redirect_url !== undefined) {
|
|
90
98
|
try {
|
|
91
99
|
assertHttpUrl(redirect_url);
|
|
@@ -105,4 +113,87 @@ export function registerFormTools(server) {
|
|
|
105
113
|
return j({ ...dbToolError(error), code: 'INTERNAL_ERROR' });
|
|
106
114
|
return j({ form: data });
|
|
107
115
|
});
|
|
116
|
+
server.registerTool('update_form', {
|
|
117
|
+
title: 'Update Form',
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
119
|
+
description: 'Update a project form without changing its slug or submissions. Settings edits preserve other settings. Requires approval in standard mode.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
form_id: z.string().uuid(),
|
|
122
|
+
name: z.string().min(1).optional(),
|
|
123
|
+
fields: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
124
|
+
redirect_url: z.string().url().optional(),
|
|
125
|
+
double_opt_in: z.boolean().optional(),
|
|
126
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
127
|
+
approval_id: z.string().uuid().optional(),
|
|
128
|
+
},
|
|
129
|
+
}, withApproval('update_form', async ({ form_id, name, fields, redirect_url, double_opt_in, status }) => {
|
|
130
|
+
if (fields !== undefined) {
|
|
131
|
+
const error = validateFields(fields);
|
|
132
|
+
if (error)
|
|
133
|
+
return j({ error, code: 'validation_failed' });
|
|
134
|
+
}
|
|
135
|
+
if (redirect_url !== undefined) {
|
|
136
|
+
try {
|
|
137
|
+
assertHttpUrl(redirect_url);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return j({ error: 'redirect_url must use http or https', code: 'validation_failed' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const projectId = getProjectId();
|
|
144
|
+
const { data: form, error: readError } = await db().from('signup_forms').select('id, settings')
|
|
145
|
+
.eq('id', form_id).eq('project_id', projectId).maybeSingle();
|
|
146
|
+
if (readError)
|
|
147
|
+
return j(dbToolError(readError));
|
|
148
|
+
if (!form)
|
|
149
|
+
return j({ error: 'Form not found', code: 'NOT_FOUND' });
|
|
150
|
+
const patch = {};
|
|
151
|
+
if (name !== undefined)
|
|
152
|
+
patch.name = name;
|
|
153
|
+
if (fields !== undefined)
|
|
154
|
+
patch.fields = fields;
|
|
155
|
+
if (status !== undefined)
|
|
156
|
+
patch.status = status;
|
|
157
|
+
const settingsChanged = redirect_url !== undefined || double_opt_in !== undefined;
|
|
158
|
+
if (settingsChanged) {
|
|
159
|
+
patch.settings = { ...form.settings,
|
|
160
|
+
...(redirect_url !== undefined ? { redirect_url } : {}),
|
|
161
|
+
...(double_opt_in !== undefined ? { double_opt_in } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!Object.keys(patch).length)
|
|
165
|
+
return j({ error: 'Provide at least one form field to update', code: 'validation_failed' });
|
|
166
|
+
patch.updated_at = new Date().toISOString();
|
|
167
|
+
let query = db().from('signup_forms').update(patch).eq('id', form_id).eq('project_id', projectId);
|
|
168
|
+
// Do not overwrite a concurrent settings edit with the earlier JSON snapshot.
|
|
169
|
+
if (settingsChanged)
|
|
170
|
+
query = query.eq('settings', JSON.stringify(form.settings));
|
|
171
|
+
const { data, error } = await query.select().maybeSingle();
|
|
172
|
+
if (error)
|
|
173
|
+
return j(dbToolError(error));
|
|
174
|
+
if (!data)
|
|
175
|
+
return j({ error: 'Form changed; read it again before retrying', code: 'conflict' });
|
|
176
|
+
await logAudit({ action: 'form.updated', resourceType: 'form', resourceId: form_id, details: patch });
|
|
177
|
+
return j({ form: data });
|
|
178
|
+
}));
|
|
179
|
+
server.registerTool('archive_form', {
|
|
180
|
+
title: 'Archive Form',
|
|
181
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
182
|
+
description: 'Reversibly archive a project form, or restore it with archived=false. Keeps submissions. Requires approval in standard mode.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
form_id: z.string().uuid(),
|
|
185
|
+
archived: z.boolean().optional().default(true),
|
|
186
|
+
approval_id: z.string().uuid().optional(),
|
|
187
|
+
},
|
|
188
|
+
}, withApproval('archive_form', async ({ form_id, archived = true }) => {
|
|
189
|
+
const status = archived ? 'archived' : 'active';
|
|
190
|
+
const { data, error } = await db().from('signup_forms').update({ status, updated_at: new Date().toISOString() })
|
|
191
|
+
.eq('id', form_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
192
|
+
if (error)
|
|
193
|
+
return j(dbToolError(error));
|
|
194
|
+
if (!data)
|
|
195
|
+
return j({ error: 'Form not found', code: 'NOT_FOUND' });
|
|
196
|
+
await logAudit({ action: 'form.updated', resourceType: 'form', resourceId: form_id, details: { status } });
|
|
197
|
+
return j({ form: data });
|
|
198
|
+
}));
|
|
108
199
|
}
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { assertHttpUrl, RESERVED_LINK_SLUGS } from '../lib/url-validation.js';
|
|
6
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
6
8
|
import { dbToolError } from '../lib/db-error.js';
|
|
7
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
8
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
@@ -85,6 +87,42 @@ export function registerShortLinkTools(server) {
|
|
|
85
87
|
return j({ ...dbToolError(error), code: 'INTERNAL_ERROR' });
|
|
86
88
|
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
87
89
|
});
|
|
90
|
+
server.registerTool('update_short_link', {
|
|
91
|
+
title: 'Update Short Link',
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
93
|
+
description: 'Update a project short link or reversibly disable/re-enable it. Keeps click history. Requires approval in standard mode.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
short_link_id: z.string().uuid(),
|
|
96
|
+
destination_url: z.string().url().optional(),
|
|
97
|
+
disabled: z.boolean().optional(),
|
|
98
|
+
reason: z.string().optional(),
|
|
99
|
+
approval_id: z.string().uuid().optional(),
|
|
100
|
+
},
|
|
101
|
+
}, withApproval('update_short_link', async ({ short_link_id, destination_url, disabled, reason }) => {
|
|
102
|
+
const patch = {};
|
|
103
|
+
if (destination_url !== undefined) {
|
|
104
|
+
try {
|
|
105
|
+
patch.destination_url = assertHttpUrl(destination_url);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return j({ error: 'destination_url must use http or https', code: 'validation_failed' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (disabled !== undefined) {
|
|
112
|
+
patch.disabled_at = disabled ? new Date().toISOString() : null;
|
|
113
|
+
patch.disabled_reason = disabled ? reason ?? null : null;
|
|
114
|
+
}
|
|
115
|
+
if (!Object.keys(patch).length)
|
|
116
|
+
return j({ error: 'Provide destination_url or disabled', code: 'validation_failed' });
|
|
117
|
+
const { data, error } = await db().from('short_links').update(patch)
|
|
118
|
+
.eq('id', short_link_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
119
|
+
if (error)
|
|
120
|
+
return j(dbToolError(error));
|
|
121
|
+
if (!data)
|
|
122
|
+
return j({ error: 'Short link not found', code: 'NOT_FOUND' });
|
|
123
|
+
await logAudit({ action: 'short_link.updated', resourceType: 'short_link', resourceId: short_link_id, details: patch });
|
|
124
|
+
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
125
|
+
}));
|
|
88
126
|
server.registerTool('get_short_link_stats', {
|
|
89
127
|
title: 'Get Short Link Stats',
|
|
90
128
|
annotations: {
|
|
@@ -99,7 +137,7 @@ export function registerShortLinkTools(server) {
|
|
|
99
137
|
const projectId = getProjectId();
|
|
100
138
|
const { data: link, error: linkError } = await db()
|
|
101
139
|
.from('short_links')
|
|
102
|
-
.select('id, slug, destination_url, click_count, last_clicked_at, created_at')
|
|
140
|
+
.select('id, slug, destination_url, click_count, last_clicked_at, created_at, disabled_at, disabled_reason')
|
|
103
141
|
.eq('id', short_link_id)
|
|
104
142
|
.eq('project_id', projectId)
|
|
105
143
|
.maybeSingle();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.215",
|
|
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",
|