@sendinel/mcp-server 1.0.18 → 1.0.20
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 +2 -2
- package/build/tools/contacts.js +28 -11
- package/build/tools/social-posts.js +118 -1
- package/package.json +1 -1
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
|
|
15
|
+
Sendinel currently exposes 34 tool groups and 314 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
|
|-------|-------|---------|
|
|
@@ -45,7 +45,7 @@ Sendinel currently exposes 34 tool groups and 311 tools (gateway mode adds 3 met
|
|
|
45
45
|
| `sites` | 5 | Site, sender, and brand voice management |
|
|
46
46
|
| `skills` | 2 | Operator skills catalog |
|
|
47
47
|
| `sms` | 7 | SMS send, log, consent, inbound |
|
|
48
|
-
| `social-posts` |
|
|
48
|
+
| `social-posts` | 9 | Social post creation, adaptation, scheduling state, connection health, engagement reads, archive/delete |
|
|
49
49
|
| `surveys` | 5 | Survey creation, listing, status, results |
|
|
50
50
|
| `templates` | 7 | Email template CRUD, version restore, migration translation |
|
|
51
51
|
| `warmup` | 4 | IP/domain warmup schedule control |
|
package/build/tools/contacts.js
CHANGED
|
@@ -2241,16 +2241,30 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2241
2241
|
});
|
|
2242
2242
|
if (error) {
|
|
2243
2243
|
// Fallback: inline query if RPC not yet available
|
|
2244
|
-
const
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2244
|
+
const SCHEMA_SAMPLE_LIMIT = 500;
|
|
2245
|
+
async function sampleSubscriberProperties(scopeToSite) {
|
|
2246
|
+
let query = db()
|
|
2247
|
+
.from('subscribers')
|
|
2248
|
+
.select('custom_properties')
|
|
2249
|
+
.eq('project_id', projectId)
|
|
2250
|
+
.not('custom_properties', 'is', null)
|
|
2251
|
+
.order('created_at', { ascending: false });
|
|
2252
|
+
if (scopeToSite)
|
|
2253
|
+
query = query.eq('source_site', site_id);
|
|
2254
|
+
return query.limit(SCHEMA_SAMPLE_LIMIT);
|
|
2255
|
+
}
|
|
2256
|
+
const siteSample = await sampleSubscriberProperties(true);
|
|
2257
|
+
if (siteSample.error)
|
|
2258
|
+
return txt(`Error: ${siteSample.error.message}`);
|
|
2259
|
+
let rows = siteSample.data ?? [];
|
|
2260
|
+
let scope = 'site';
|
|
2261
|
+
if (rows.length === 0) {
|
|
2262
|
+
const projectSample = await sampleSubscriberProperties(false);
|
|
2263
|
+
if (projectSample.error)
|
|
2264
|
+
return txt(`Error: ${projectSample.error.message}`);
|
|
2265
|
+
rows = projectSample.data ?? [];
|
|
2266
|
+
scope = 'project';
|
|
2267
|
+
}
|
|
2254
2268
|
const keyMap = new Map();
|
|
2255
2269
|
for (const row of rows ?? []) {
|
|
2256
2270
|
const props = row.custom_properties;
|
|
@@ -2277,9 +2291,12 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
2277
2291
|
}));
|
|
2278
2292
|
return j({
|
|
2279
2293
|
site_id,
|
|
2294
|
+
scope,
|
|
2280
2295
|
total_keys: schema.length,
|
|
2281
2296
|
schema,
|
|
2282
|
-
note:
|
|
2297
|
+
note: scope === 'project'
|
|
2298
|
+
? `No contacts are tagged to site ${site_id} via source_site — sampled custom_properties across the whole project instead. Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).`
|
|
2299
|
+
: 'Use merge_tag syntax in email body/subject. For sensitive data, add a fallback: {{custom.plan|subscriber}}. ⚠️ custom_properties data passes through the Anthropic API — avoid storing sensitive PII (SSNs, passwords, financial account numbers).',
|
|
2283
2300
|
});
|
|
2284
2301
|
}
|
|
2285
2302
|
return j({
|
|
@@ -13,7 +13,27 @@ const PLATFORMS = [
|
|
|
13
13
|
'instagram', 'linkedin', 'twitter', 'x', 'facebook', 'tiktok',
|
|
14
14
|
'youtube', 'pinterest', 'threads', 'reddit', 'bluesky', 'google_business',
|
|
15
15
|
];
|
|
16
|
-
const STATUSES = ['draft', 'scheduled', 'queued', 'publishing', 'published', 'failed', 'archived'];
|
|
16
|
+
const STATUSES = ['draft', 'scheduled', 'queued', 'publishing', 'published', 'partially_published', 'failed', 'archived'];
|
|
17
|
+
const ENGAGEMENT_STATUSES = ['open', 'replied', 'dismissed', 'failed', 'expired'];
|
|
18
|
+
const SOCIAL_CONNECTION_COLUMNS = [
|
|
19
|
+
'platform', 'account_name', 'account_id', 'account_status', 'expires_at',
|
|
20
|
+
'follower_count', 'follower_count_synced_at', 'updated_at', 'created_at',
|
|
21
|
+
];
|
|
22
|
+
const SOCIAL_ENGAGEMENT_LIST_COLUMNS = [
|
|
23
|
+
'id', 'social_post_id', 'platform', 'engagement_type', 'direction',
|
|
24
|
+
'sender_name', 'sender_handle', 'body', 'status', 'received_at',
|
|
25
|
+
'responded_at', 'created_at',
|
|
26
|
+
];
|
|
27
|
+
// `metadata` is deliberately not selected: nothing in the dashboard writes it, so its contents are
|
|
28
|
+
// unknown, and the v1 social-inbox route keeps to this same plain column list. Re-add it only once
|
|
29
|
+
// something defines what it holds.
|
|
30
|
+
const SOCIAL_ENGAGEMENT_DETAIL_COLUMNS = [
|
|
31
|
+
...SOCIAL_ENGAGEMENT_LIST_COLUMNS,
|
|
32
|
+
'dismissed_at',
|
|
33
|
+
];
|
|
34
|
+
function selectFields(row, columns) {
|
|
35
|
+
return Object.fromEntries(columns.map((column) => [column, row[column]]));
|
|
36
|
+
}
|
|
17
37
|
function cleanText(value) {
|
|
18
38
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
19
39
|
}
|
|
@@ -101,6 +121,103 @@ async function requireProjectCampaign(campaignId) {
|
|
|
101
121
|
throw new Error('Email campaign not found for this project');
|
|
102
122
|
}
|
|
103
123
|
export function registerSocialPostTools(server) {
|
|
124
|
+
server.registerTool('list_social_connections', {
|
|
125
|
+
title: 'List Social Connections',
|
|
126
|
+
annotations: {
|
|
127
|
+
readOnlyHint: true,
|
|
128
|
+
destructiveHint: false,
|
|
129
|
+
idempotentHint: true,
|
|
130
|
+
openWorldHint: false,
|
|
131
|
+
},
|
|
132
|
+
description: 'List this organization\'s connected social accounts with platform, account identity, connection status, and when access expires. Use before scheduling to detect no connected accounts, expired access, or a reauth-required status.',
|
|
133
|
+
inputSchema: {},
|
|
134
|
+
}, async () => {
|
|
135
|
+
const projectId = getProjectId();
|
|
136
|
+
const { data: project, error: projectError } = await db()
|
|
137
|
+
.from('projects')
|
|
138
|
+
.select('org_id')
|
|
139
|
+
.eq('id', projectId)
|
|
140
|
+
.maybeSingle();
|
|
141
|
+
if (projectError)
|
|
142
|
+
return txt(`Error: ${projectError.message}`);
|
|
143
|
+
if (!project?.org_id)
|
|
144
|
+
return txt('Error: Project organization not found.');
|
|
145
|
+
const { data, error } = await db()
|
|
146
|
+
.from('social_platform_connections')
|
|
147
|
+
.select(SOCIAL_CONNECTION_COLUMNS.join(', '))
|
|
148
|
+
.eq('org_id', project.org_id)
|
|
149
|
+
.order('platform', { ascending: true });
|
|
150
|
+
if (error)
|
|
151
|
+
return txt(`Error: ${error.message}`);
|
|
152
|
+
const connections = (data ?? [])
|
|
153
|
+
.map((row) => selectFields(row, SOCIAL_CONNECTION_COLUMNS));
|
|
154
|
+
if (connections.length === 0) {
|
|
155
|
+
return j({ connections: [], message: 'No social accounts are connected.' });
|
|
156
|
+
}
|
|
157
|
+
return j({ connections });
|
|
158
|
+
});
|
|
159
|
+
server.registerTool('list_social_engagements', {
|
|
160
|
+
title: 'List Social Engagements',
|
|
161
|
+
annotations: {
|
|
162
|
+
readOnlyHint: true,
|
|
163
|
+
destructiveHint: false,
|
|
164
|
+
idempotentHint: true,
|
|
165
|
+
openWorldHint: false,
|
|
166
|
+
},
|
|
167
|
+
description: 'List inbound social inbox engagements such as comments, replies, direct messages, and mentions across connected platforms. Read-only: this tool never responds on a user\'s behalf.',
|
|
168
|
+
inputSchema: {
|
|
169
|
+
status: z.enum([...ENGAGEMENT_STATUSES, 'all']).optional().default('open'),
|
|
170
|
+
platform: z.string().optional(),
|
|
171
|
+
social_post_id: z.string().uuid().optional(),
|
|
172
|
+
limit: z.number().int().min(1).max(200).optional().default(50),
|
|
173
|
+
},
|
|
174
|
+
}, async ({ status, platform, social_post_id, limit }) => {
|
|
175
|
+
let query = db()
|
|
176
|
+
.from('social_engagements')
|
|
177
|
+
.select(SOCIAL_ENGAGEMENT_LIST_COLUMNS.join(', '))
|
|
178
|
+
.eq('project_id', getProjectId())
|
|
179
|
+
.eq('direction', 'inbound')
|
|
180
|
+
.order('received_at', { ascending: false })
|
|
181
|
+
.limit(limit ?? 50);
|
|
182
|
+
if (status && status !== 'all')
|
|
183
|
+
query = query.eq('status', status);
|
|
184
|
+
if (platform)
|
|
185
|
+
query = query.eq('platform', platform);
|
|
186
|
+
if (social_post_id)
|
|
187
|
+
query = query.eq('social_post_id', social_post_id);
|
|
188
|
+
const { data, error } = await query;
|
|
189
|
+
if (error)
|
|
190
|
+
return txt(`Error: ${error.message}`);
|
|
191
|
+
const engagements = (data ?? [])
|
|
192
|
+
.map((row) => selectFields(row, SOCIAL_ENGAGEMENT_LIST_COLUMNS));
|
|
193
|
+
if (engagements.length === 0) {
|
|
194
|
+
return j({ engagements: [], message: 'No social engagements matched the requested filters.' });
|
|
195
|
+
}
|
|
196
|
+
return j({ engagements });
|
|
197
|
+
});
|
|
198
|
+
server.registerTool('get_social_engagement', {
|
|
199
|
+
title: 'Get Social Engagement',
|
|
200
|
+
annotations: {
|
|
201
|
+
readOnlyHint: true,
|
|
202
|
+
destructiveHint: false,
|
|
203
|
+
idempotentHint: true,
|
|
204
|
+
openWorldHint: false,
|
|
205
|
+
},
|
|
206
|
+
description: 'Get one inbound social inbox engagement, including a comment, reply, direct message, or mention, for human review. Read-only: this tool does not respond.',
|
|
207
|
+
inputSchema: { engagement_id: z.string().uuid() },
|
|
208
|
+
}, async ({ engagement_id }) => {
|
|
209
|
+
const { data, error } = await db()
|
|
210
|
+
.from('social_engagements')
|
|
211
|
+
.select(SOCIAL_ENGAGEMENT_DETAIL_COLUMNS.join(', '))
|
|
212
|
+
.eq('project_id', getProjectId())
|
|
213
|
+
.eq('id', engagement_id)
|
|
214
|
+
.maybeSingle();
|
|
215
|
+
if (error)
|
|
216
|
+
return txt(`Error: ${error.message}`);
|
|
217
|
+
if (!data)
|
|
218
|
+
return j({ error: 'Social engagement not found', code: 'NOT_FOUND' });
|
|
219
|
+
return j(selectFields(data, SOCIAL_ENGAGEMENT_DETAIL_COLUMNS));
|
|
220
|
+
});
|
|
104
221
|
server.registerTool('list_social_posts', {
|
|
105
222
|
title: 'List Social Posts',
|
|
106
223
|
annotations: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.20",
|
|
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",
|