@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 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 311 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 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` | 6 | Social post creation, adaptation, scheduling state, archive/delete |
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 |
@@ -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 { data: rows, error: qErr } = await db()
2245
- .from('subscribers')
2246
- .select('custom_properties')
2247
- .eq('project_id', projectId)
2248
- .eq('source_site', site_id)
2249
- .not('custom_properties', 'is', null)
2250
- .order('created_at', { ascending: false })
2251
- .limit(500);
2252
- if (qErr)
2253
- return txt(`Error: ${qErr.message}`);
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: '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).',
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.18",
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",