@sendinel/mcp-server 1.0.22 → 1.0.24

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.
@@ -1,3 +1,4 @@
1
+ import { fetchAllRows } from '@sendinel/domain';
1
2
  import { z } from 'zod';
2
3
  import { db, adminDb } from '../db.js';
3
4
  import { getProjectId } from '../project.js';
@@ -431,42 +432,54 @@ export function registerAnalyticsTools(server) {
431
432
  if (siteError)
432
433
  return { content: [{ type: 'text', text: `Error: ${siteError.message}` }] };
433
434
  const trackingEnabled = site?.tracking_enabled ?? false;
434
- const { data, error } = await db()
435
+ const { data, error, pageCount: logPageCount } = await fetchAllRows(async (offset, limit) => db()
435
436
  .from('email_log')
436
437
  .select('id, provider_message_id, status, sent_at')
437
438
  .eq('project_id', projectId)
438
439
  .eq('site_id', site_id)
439
440
  .gte('sent_at', `${start_date}T00:00:00Z`)
440
- .lte('sent_at', `${end}T23:59:59Z`);
441
+ .lte('sent_at', `${end}T23:59:59Z`)
442
+ .order('id', { ascending: true })
443
+ .range(offset, offset + limit - 1));
441
444
  if (error)
442
445
  return { content: [{ type: 'text', text: `Error: ${error.message}` }] };
443
446
  const rows = (data ?? []);
444
447
  let eventCounts = { opened: 0, clicked: 0 };
448
+ let eventRows = [];
449
+ let eventPageCount = 0;
445
450
  if (trackingEnabled) {
446
- const { data: events, error: eventsError } = await db()
451
+ const { data: events, error: eventsError, pageCount } = await fetchAllRows(async (offset, limit) => db()
447
452
  .from('email_events')
448
453
  .select('provider_message_id, event_type')
449
454
  .eq('project_id', projectId)
450
455
  .eq('site_id', site_id)
451
456
  .in('event_type', ['open', 'click'])
452
457
  .gte('occurred_at', `${start_date}T00:00:00Z`)
453
- .lte('occurred_at', `${end}T23:59:59Z`);
458
+ .lte('occurred_at', `${end}T23:59:59Z`)
459
+ .order('id', { ascending: true })
460
+ .range(offset, offset + limit - 1));
454
461
  if (eventsError)
455
462
  return { content: [{ type: 'text', text: `Error: ${eventsError.message}` }] };
463
+ eventRows = events ?? [];
464
+ eventPageCount = pageCount;
456
465
  const messageIds = new Set(rows.map(row => row.provider_message_id).filter((id) => Boolean(id)));
457
466
  eventCounts = {
458
- opened: distinctEventCount(events ?? [], 'open', messageIds),
459
- clicked: distinctEventCount(events ?? [], 'click', messageIds),
467
+ opened: distinctEventCount(eventRows, 'open', messageIds),
468
+ clicked: distinctEventCount(eventRows, 'click', messageIds),
460
469
  };
461
470
  }
462
471
  const metrics = computeAnalyticsMetrics(rows, trackingEnabled ? eventCounts : undefined);
463
472
  const periodDays = daysBetween(start_date, end);
464
473
  const priorStart = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -periodDays));
465
474
  const priorEnd = dateOnly(addDays(new Date(`${start_date}T00:00:00Z`), -1));
466
- const { data: subscribers } = await db()
475
+ const { data: subscribers, error: subscribersError, pageCount: subscriberPageCount } = await fetchAllRows(async (offset, limit) => db()
467
476
  .from('subscribers')
468
477
  .select('created_at, last_engaged_at, last_opened_at, last_clicked_at')
469
- .eq('project_id', projectId);
478
+ .eq('project_id', projectId)
479
+ .order('id', { ascending: true })
480
+ .range(offset, offset + limit - 1));
481
+ if (subscribersError)
482
+ return { content: [{ type: 'text', text: `Error: ${subscribersError.message}` }] };
470
483
  const subscriberRows = (subscribers ?? []);
471
484
  const currentNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${start_date}T00:00:00Z` && s.created_at <= `${end}T23:59:59Z`).length;
472
485
  const priorNew = subscriberRows.filter(s => s.created_at && s.created_at >= `${priorStart}T00:00:00Z` && s.created_at <= `${priorEnd}T23:59:59Z`).length;
@@ -488,6 +501,15 @@ export function registerAnalyticsTools(server) {
488
501
  list_growth_rate: subscriberRows.length > 0 ? pctNumber(currentNew / subscriberRows.length) : 0,
489
502
  inactive_pct: subscriberRows.length > 0 ? pctNumber(inactive / subscriberRows.length) : 0,
490
503
  sender_health_score: computeSenderHealthScore(metrics, trackingEnabled),
504
+ coverage: {
505
+ log_rows_scanned: rows.length,
506
+ log_page_count: logPageCount,
507
+ event_rows_scanned: trackingEnabled ? eventRows.length : null,
508
+ event_page_count: trackingEnabled ? eventPageCount : null,
509
+ subscriber_rows_scanned: subscriberRows.length,
510
+ subscriber_page_count: subscriberPageCount,
511
+ complete: true,
512
+ },
491
513
  };
492
514
  if (trackingEnabled) {
493
515
  response.open_rate = metrics.sent > 0 ? percent(metrics.open_rate) : 'N/A';
@@ -2283,54 +2283,38 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2283
2283
  },
2284
2284
  }, async ({ site_id }) => {
2285
2285
  const projectId = getProjectId();
2286
- // Sample recent contacts to discover keys (LIMIT 500 is enough for schema discovery)
2287
- const SCHEMA_SAMPLE_LIMIT = 500;
2288
- async function sampleSubscriberProperties(scopeToSite) {
2289
- let query = db()
2290
- .from('subscribers')
2291
- .select('custom_properties')
2292
- .eq('project_id', projectId)
2293
- .not('custom_properties', 'is', null)
2294
- .order('created_at', { ascending: false });
2295
- if (scopeToSite)
2296
- query = query.eq('source_site', site_id);
2297
- return query.limit(SCHEMA_SAMPLE_LIMIT);
2298
- }
2299
- const siteSample = await sampleSubscriberProperties(true);
2300
- if (siteSample.error)
2301
- return txt(`Error: ${siteSample.error.message}`);
2302
- let rows = siteSample.data ?? [];
2286
+ async function loadSchemaKeys(siteScope) {
2287
+ const { data, error } = await db().rpc('get_contact_schema_keys', {
2288
+ p_project_id: projectId,
2289
+ ...(siteScope ? { p_site_id: siteScope } : {}),
2290
+ });
2291
+ if (error)
2292
+ return { error: `Error: ${error.message}` };
2293
+ const result = (Array.isArray(data) ? data[0] : data);
2294
+ if (typeof result?.has_membership !== 'boolean' || !Array.isArray(result.keys)) {
2295
+ return { error: 'Error: get_contact_schema_keys returned an unreadable result' };
2296
+ }
2297
+ return result;
2298
+ }
2299
+ // Site membership is hybrid (#3489): subscribers.source_site OR a site_subscriptions
2300
+ // row, counted once. A real site whose members have no custom_properties yet is still
2301
+ // site-scoped; project scope is only for a site with no members at all.
2302
+ let loaded = await loadSchemaKeys(site_id);
2303
+ if ('error' in loaded)
2304
+ return txt(loaded.error);
2303
2305
  let scope = 'site';
2304
- if (rows.length === 0) {
2305
- const projectSample = await sampleSubscriberProperties(false);
2306
- if (projectSample.error)
2307
- return txt(`Error: ${projectSample.error.message}`);
2308
- rows = projectSample.data ?? [];
2306
+ if (!loaded.has_membership) {
2307
+ loaded = await loadSchemaKeys(null);
2308
+ if ('error' in loaded)
2309
+ return txt(loaded.error);
2309
2310
  scope = 'project';
2310
2311
  }
2311
- const keyMap = new Map();
2312
- for (const row of rows ?? []) {
2313
- const props = row.custom_properties;
2314
- if (!props)
2315
- continue;
2316
- for (const [k, v] of Object.entries(props)) {
2317
- if (!keyMap.has(k))
2318
- keyMap.set(k, { values: new Set(), count: 0 });
2319
- const entry = keyMap.get(k);
2320
- entry.count++;
2321
- if (v !== null && v !== undefined && entry.values.size < 3) {
2322
- entry.values.add(String(v));
2323
- }
2324
- }
2325
- }
2326
- const schema = Array.from(keyMap.entries())
2327
- .sort((a, b) => b[1].count - a[1].count)
2328
- .map(([key, { values, count }]) => ({
2312
+ const schema = loaded.keys.map(({ key, contacts_with_this_key, example_values }) => ({
2329
2313
  key,
2330
2314
  merge_tag: `{{custom.${key}}}`,
2331
2315
  merge_tag_with_fallback: `{{custom.${key}|fallback}}`,
2332
- example_values: Array.from(values),
2333
- contacts_with_this_key: count,
2316
+ example_values,
2317
+ contacts_with_this_key,
2334
2318
  }));
2335
2319
  return j({
2336
2320
  site_id,
@@ -2338,7 +2322,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
2338
2322
  total_keys: schema.length,
2339
2323
  schema,
2340
2324
  note: scope === 'project'
2341
- ? `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).`
2325
+ ? `No contacts are linked to site ${site_id} via source_site or site_subscriptions — read 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).`
2342
2326
  : '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).',
2343
2327
  });
2344
2328
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
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",