@sendinel/mcp-server 1.0.152 → 1.0.154

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.
@@ -23,7 +23,6 @@ export const APPROVAL_EXEMPT = {
23
23
  approve_approval: "is the approval step itself: only a person-role OAuth connection can decide its own pending request, bound to that token and project, audited; sends nothing",
24
24
  approve_data_proposal: "turns a proposal into an AI draft for review; sends nothing",
25
25
  approve_draft: "marks a draft ready; sending still requires a gated send or activation",
26
- cancel_approval: "only moves an unused approval to rejected (never grants anything); reduces what can run",
27
26
  cancel_send_queue_rows: "cancels queued mail before it sends; reduces sending",
28
27
  clone_campaign: "creates a draft copy of a campaign; nothing sends until activated (gated)",
29
28
  clone_sequence_template: "creates a draft campaign from a template; nothing sends until activated (gated)",
@@ -22,8 +22,10 @@ async function clientName(fallback) {
22
22
  }
23
23
  export async function cancelApproval(approvalId, transportClientName) {
24
24
  const actor = requestContext();
25
- if (!actor?.scopes?.some(scope => scope === 'write' || scope === 'admin')) {
26
- return refuse('approval_forbidden', 'Cancelling an approval requires write scope.');
25
+ const canCancelProject = actor?.scopes?.some(scope => scope === 'write' || scope === 'admin');
26
+ const ownTokenId = actor?.authKind === 'oauth' && actor.scopes?.includes('read') ? actor.oauthTokenId : null;
27
+ if (!actor || (!canCancelProject && !ownTokenId)) {
28
+ return refuse('approval_forbidden', 'Cancellation requires write scope or the requesting OAuth token.');
27
29
  }
28
30
  try {
29
31
  const projectId = getProjectId();
@@ -31,13 +33,17 @@ export async function cancelApproval(approvalId, transportClientName) {
31
33
  const now = new Date().toISOString();
32
34
  // A single conditional write arbitrates cancellation versus approval/consumption.
33
35
  // Preserve the original decision provenance; cancellation never grants authority.
34
- const { data, error } = await store().from('action_approvals')
36
+ const cancellation = store().from('action_approvals')
35
37
  .update({ status: 'rejected' })
36
38
  .eq('id', approvalId).eq('project_id', projectId)
37
- .in('status', ['pending', 'approved']).is('used_at', null).gt('expires_at', now)
38
- .select('id').maybeSingle();
39
+ .in('status', ['pending', 'approved']).is('used_at', null).gt('expires_at', now);
40
+ // Ownership is part of the write, so concurrent changes cannot cross tokens.
41
+ const bound = canCancelProject ? cancellation : cancellation.eq('oauth_token_id', ownTokenId);
42
+ const { data, error } = await bound.select('id').maybeSingle();
39
43
  if (error)
40
44
  return refuse('approval_update_failed', 'Unable to cancel the approval. Check its status before retrying.');
45
+ if (!data && !canCancelProject)
46
+ return refuse('approval_forbidden', 'No cancellable approval for this OAuth token in this project.');
41
47
  if (!data)
42
48
  return refuse('approval_not_cancellable', 'No unexpired, unused pending or approved approval found in this project.');
43
49
  const { error: auditError } = await store().from('audit_log').insert({
package/build/server.js CHANGED
@@ -88,7 +88,7 @@ export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
88
88
  // ── Scope enforcement ─────────────────────────────────────────────────────
89
89
  // Maps tool names to required scope. Mirrors dashboard's WRITE_TOOLS/ADMIN_TOOLS.
90
90
  const WRITE_TOOLS = new Set([
91
- 'cancel_approval', 'approve_approval',
91
+ 'approve_approval',
92
92
  'recheck_domain_dns',
93
93
  'mark_agent_message', 'draft_agent_reply', 'create_agent_mailbox',
94
94
  'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
@@ -188,7 +188,6 @@ export function testerConnectionAllowsTool(name) {
188
188
  // Retain the existing campaign activation path. A globally gated tool is not by itself
189
189
  // permission for a tester: sends, deletes and access/infrastructure writes stay refused.
190
190
  return getRequiredScope(name) === 'read'
191
- || name === 'cancel_approval'
192
191
  || TESTER_REQUEST_TOOLS.has(name)
193
192
  || name === 'activate_campaigns'
194
193
  || name === 'update_campaign_status';
@@ -324,7 +323,7 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
324
323
  const required = getRequiredScope(name);
325
324
  const tester = isTesterConnection(scopes);
326
325
  const agent = isAgentConnection(scopes);
327
- const allowed = () => name === 'cancel_approval' ? scopeAllows(scopes, 'write') : agent ? agentConnectionAllowsTool(name) : tester
326
+ const allowed = () => agent ? agentConnectionAllowsTool(name) : tester
328
327
  ? testerConnectionAllowsTool(name)
329
328
  : scopeAllows(scopes, required) || (scopeAllows(scopes, 'read') && isHumanRequestableAdminTool(name));
330
329
  if (!allowed()) {
@@ -11,7 +11,7 @@ const j = (data) => txt(JSON.stringify(data, null, 2));
11
11
  export function registerApprovalTools(server, options) {
12
12
  server.registerTool('cancel_approval', {
13
13
  title: 'Cancel Approval',
14
- description: 'Cancel an unexpired, unused pending or approved action approval in this project. Requires write scope. Sets it to rejected; cannot approve or undo cancellation.',
14
+ description: 'Cancel an unexpired, unused pending or approved action approval in this project. Read-scoped OAuth connections can cancel only their own token’s requests; write/admin credentials can cancel any eligible request in this project. Sets it to rejected; cannot approve or undo cancellation.',
15
15
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
16
16
  inputSchema: { approval_id: z.string().uuid() },
17
17
  }, async ({ approval_id }) => {
@@ -2,7 +2,7 @@ import { normalizeTagInput, tagInputWarnings } from '../lib/tags.js';
2
2
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { CONTACT_ENRICHMENT_PROVIDERS } from '@desler/models';
4
4
  import { createContactEnrichmentAdapters, enrichContactFromConnections, } from '@desler/models/contact-enrichment';
5
- import { buildPreviewAttentionEnvelope, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
5
+ import { buildPreviewAttentionEnvelope, applySegmentRules, isSegmentRulesError, ContactPropertyAnomalyLogger, normalizeContactProperties, validateContactPropertyPatch, getAttentionDecision, getContactNeighborhood, getScheduledTouches, findContactDuplicateGroups, readAllPages, readAllRows, chunkIds, } from '@sendinel/domain';
6
6
  import { z } from 'zod';
7
7
  import { callSendinelV1 } from '../lib/v1-client.js';
8
8
  import { db, projectClientForWriteback } from '../db.js';
@@ -45,22 +45,6 @@ const CUSTOM_FIELD_COLUMNS = 'id, key, label, type, required, options, display_o
45
45
  async function registeredCustomFields(projectId) {
46
46
  return readAllRows(db().from('custom_field_definitions').select(CUSTOM_FIELD_COLUMNS).eq('project_id', projectId));
47
47
  }
48
- function keyDistance(left, right) {
49
- let row = Array.from({ length: right.length + 1 }, (_, index) => index);
50
- for (let i = 0; i < left.length; i++) {
51
- const next = [i + 1];
52
- for (let j = 0; j < right.length; j++)
53
- next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + (left[i] === right[j] ? 0 : 1)));
54
- row = next;
55
- }
56
- return row[right.length];
57
- }
58
- function unknownCustomPropertyWarnings(patch, fields) {
59
- return Object.keys(patch).filter(key => !Object.hasOwn(fields, key)).map(key => {
60
- const near = Object.keys(fields).filter(known => keyDistance(key, known) <= 2);
61
- return `Unknown custom property "${key}"${near.length ? `; did you mean ${near.map(value => `"${value}"`).join(' or ')}?` : '; no registered definition'}`;
62
- });
63
- }
64
48
  const SUBSCRIBER_VIEW_MAX_NAME_LENGTH = 100;
65
49
  const SUBSCRIBER_VIEW_MAX_COLUMNS = 150;
66
50
  const SUBSCRIBER_VIEW_KEY_RE = /^[a-zA-Z0-9_.:-]{1,160}$/;
@@ -1836,21 +1820,10 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
1836
1820
  return j({ error: 'Custom field definitions unavailable; update not performed', code: 'INTERNAL_ERROR' });
1837
1821
  }
1838
1822
  fields = Object.fromEntries(definitions.map(field => [field.key, field]));
1839
- const values = Object.fromEntries(Object.entries(custom_properties).filter(([, value]) => value !== null));
1840
- const invalidShape = Object.entries(values).find(([key, value]) => {
1841
- if (!Object.hasOwn(fields, key))
1842
- return false;
1843
- const type = fields[key].type;
1844
- if (type === 'multi_select')
1845
- return typeof value !== 'string' && (!Array.isArray(value) || value.some(item => typeof item !== 'string'));
1846
- return typeof value === 'object' && type !== 'contact_reference';
1847
- });
1848
- const { anomalies } = normalizeContactProperties(values, { fields });
1849
- if (invalidShape || anomalies.length) {
1850
- const key = invalidShape?.[0] ?? anomalies[0].key;
1851
- return contactInputError('validation_failed', `custom_properties.${key} does not match its registered ${fields[key].type} definition or options`);
1852
- }
1853
- propertyWarnings = unknownCustomPropertyWarnings(custom_properties, fields);
1823
+ const validation = validateContactPropertyPatch(custom_properties, fields);
1824
+ if (validation.error)
1825
+ return contactInputError('validation_failed', validation.error);
1826
+ propertyWarnings = validation.warnings;
1854
1827
  }
1855
1828
  const embedOk = await hasCompaniesEmbed(projectId);
1856
1829
  const updates = {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.152",
3
+ "version": "1.0.154",
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",