@sendinel/mcp-server 1.0.188 → 1.0.189

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,8 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { z } from 'zod';
3
+ import { AI_WORK_ITEM_STATUSES } from '@sendinel/domain';
3
4
  import { db } from '../db.js';
4
5
  import { getProjectId } from '../project.js';
5
- import { dbToolError } from '../lib/db-error.js';
6
+ import { dbToolError, toolError } from '../lib/db-error.js';
6
7
  const txt = (text) => ({ content: [{ type: 'text', text }] });
7
8
  const j = (data) => txt(JSON.stringify(data, null, 2));
8
9
  const workItemSelect = [
@@ -47,32 +48,67 @@ const proposalSelect = [
47
48
  'generated_by',
48
49
  'llm_model',
49
50
  ].join(', ');
50
- const workStatusSchema = z.enum(['new', 'seen', 'accepted', 'dismissed', 'snoozed', 'resolved', 'failed']);
51
51
  const activeStatuses = ['new', 'seen', 'snoozed'];
52
52
  const contextEventTypes = ['strategic_segment_intent', 'business_priority', 'customer_journey_signal', 'campaign_opportunity', 'risk_signal'];
53
53
  const contextEventInput = {
54
- workspace_id: z.string().min(1).max(255),
54
+ workspace_id: z.string().trim().min(1).max(255),
55
55
  event_type: z.enum(contextEventTypes),
56
- title: z.string().min(1).max(255),
57
- detail: z.string().min(1).max(4000),
56
+ title: z.string().trim().min(1).max(255),
57
+ detail: z.string().trim().min(1).max(4000),
58
58
  evidence: z.record(z.string(), z.unknown()).optional(),
59
59
  suggested_actions: z.array(z.record(z.string(), z.unknown())).optional(),
60
- idempotency_key: z.string().min(1).max(255),
60
+ idempotency_key: z.string().trim().min(1).max(255),
61
61
  };
62
62
  function parseStatuses(status) {
63
- if (!status)
64
- return [...activeStatuses];
65
- const requested = status.split(',').map((item) => item.trim()).filter(Boolean);
66
- const valid = requested.filter((item) => workStatusSchema.safeParse(item).success);
67
- return valid.length ? valid : [...activeStatuses];
63
+ if (status === undefined)
64
+ return { statuses: [...activeStatuses] };
65
+ const rawParts = status.split(',').map((item) => item.trim());
66
+ if (rawParts.length === 0 || (rawParts.length === 1 && rawParts[0] === '')) {
67
+ return {
68
+ error: {
69
+ code: 'validation_failed',
70
+ error: `Invalid status "${status}". Allowed values: ${AI_WORK_ITEM_STATUSES.join(', ')}`,
71
+ },
72
+ };
73
+ }
74
+ const invalid = rawParts.filter((item) => !AI_WORK_ITEM_STATUSES.includes(item));
75
+ if (invalid.length > 0) {
76
+ return {
77
+ error: {
78
+ code: 'validation_failed',
79
+ error: `Invalid status "${invalid.join(', ')}". Allowed values: ${AI_WORK_ITEM_STATUSES.join(', ')}`,
80
+ },
81
+ };
82
+ }
83
+ return { statuses: [...new Set(rawParts)] };
68
84
  }
69
85
  async function updateWorkItemStatus(id, status, snoozedUntil) {
86
+ const { data: existing, error: existingErr } = await db()
87
+ .from('ai_work_items')
88
+ .select(workItemSelect)
89
+ .eq('project_id', getProjectId())
90
+ .eq('id', id)
91
+ .maybeSingle();
92
+ if (existingErr)
93
+ return toolError(dbToolError(existingErr));
94
+ if (!existing)
95
+ return toolError({ code: 'not_found', error: 'AI work item not found.' });
96
+ if (status === 'seen') {
97
+ // #4558: a read receipt must not undo a dismissal or other non-new status
98
+ if (existing.status !== 'new') {
99
+ return j({ work_item: existing });
100
+ }
101
+ }
70
102
  const updates = { status };
71
103
  if (status === 'dismissed')
72
104
  updates.dismissed_at = new Date().toISOString();
73
105
  if (status === 'snoozed') {
74
106
  if (!snoozedUntil)
75
- return { error: 'snoozed_until is required.' };
107
+ return toolError({ code: 'validation_failed', error: 'snoozed_until is required.' });
108
+ const snoozedTime = new Date(snoozedUntil).getTime();
109
+ if (!Number.isFinite(snoozedTime) || snoozedTime <= Date.now()) {
110
+ return toolError({ code: 'validation_failed', error: 'snoozed_until must be in the future.' });
111
+ }
76
112
  updates.snoozed_until = snoozedUntil;
77
113
  }
78
114
  else {
@@ -86,10 +122,10 @@ async function updateWorkItemStatus(id, status, snoozedUntil) {
86
122
  .select(workItemSelect)
87
123
  .maybeSingle();
88
124
  if (error)
89
- return { ...dbToolError(error) };
125
+ return toolError(dbToolError(error));
90
126
  if (!data)
91
- return { error: 'AI work item not found.' };
92
- return { work_item: data };
127
+ return toolError({ code: 'not_found', error: 'AI work item not found.' });
128
+ return j({ work_item: data });
93
129
  }
94
130
  function stableUuid(input) {
95
131
  const hex = createHash('sha256').update(input).digest('hex').slice(0, 32);
@@ -121,33 +157,87 @@ function segmentActionFromContext(eventType, suggestedActions, title, detail) {
121
157
  rules: action.rules,
122
158
  };
123
159
  }
160
+ function isSameContextEventPayload(existing, input) {
161
+ if (existing.workspace_id !== input.workspace_id.trim())
162
+ return false;
163
+ if (existing.event_type !== input.event_type)
164
+ return false;
165
+ if (existing.title !== input.title.trim())
166
+ return false;
167
+ if (existing.detail !== input.detail.trim())
168
+ return false;
169
+ const existingEvidence = existing.evidence && typeof existing.evidence === 'object' ? existing.evidence : {};
170
+ const inputEvidence = input.evidence && typeof input.evidence === 'object' ? input.evidence : {};
171
+ if (JSON.stringify(existingEvidence) !== JSON.stringify(inputEvidence))
172
+ return false;
173
+ const existingActions = Array.isArray(existing.suggested_actions) ? existing.suggested_actions : [];
174
+ const inputActions = Array.isArray(input.suggested_actions) ? input.suggested_actions : [];
175
+ if (JSON.stringify(existingActions) !== JSON.stringify(inputActions))
176
+ return false;
177
+ return true;
178
+ }
179
+ async function buildContextReplay(projectId, event) {
180
+ let workItem = null;
181
+ if (event.work_item_id) {
182
+ const { data } = await db()
183
+ .from('ai_work_items')
184
+ .select(workItemSelect)
185
+ .eq('project_id', projectId)
186
+ .eq('id', event.work_item_id)
187
+ .maybeSingle();
188
+ if (data)
189
+ workItem = data;
190
+ }
191
+ let proposals = [];
192
+ if (Array.isArray(event.proposal_ids) && event.proposal_ids.length) {
193
+ const { data } = await db()
194
+ .from('agent_proposals')
195
+ .select(proposalSelect)
196
+ .eq('project_id', projectId)
197
+ .in('id', event.proposal_ids);
198
+ if (data)
199
+ proposals = data;
200
+ }
201
+ return j({ context_event: event, work_item: workItem, proposals, idempotent: true });
202
+ }
124
203
  async function submitContextEvent(input) {
125
204
  const projectId = getProjectId();
205
+ const normalizedKey = input.idempotency_key.trim();
126
206
  const existing = await db()
127
207
  .from('ai_context_events')
128
208
  .select('*')
129
209
  .eq('project_id', projectId)
130
210
  .eq('source', 'synchronex')
131
- .eq('idempotency_key', input.idempotency_key)
211
+ .eq('idempotency_key', normalizedKey)
132
212
  .maybeSingle();
133
213
  if (existing.error)
134
- return { ...dbToolError(existing.error) };
135
- if (existing.data)
136
- return { context_event: existing.data, work_item: null, proposals: [], idempotent: true };
137
- const orgId = await getOrgIdForProject(projectId);
214
+ return toolError(dbToolError(existing.error));
215
+ if (existing.data) {
216
+ if (!isSameContextEventPayload(existing.data, input)) {
217
+ return toolError({ code: 'conflict', error: 'idempotency_key was already used with different content' });
218
+ }
219
+ return buildContextReplay(projectId, existing.data);
220
+ }
221
+ let orgId;
222
+ try {
223
+ orgId = await getOrgIdForProject(projectId);
224
+ }
225
+ catch (err) {
226
+ return toolError(dbToolError(err));
227
+ }
138
228
  const inserted = await db()
139
229
  .from('ai_context_events')
140
230
  .insert({
141
231
  org_id: orgId,
142
232
  project_id: projectId,
143
- workspace_id: input.workspace_id,
233
+ workspace_id: input.workspace_id.trim(),
144
234
  source: 'synchronex',
145
235
  event_type: input.event_type,
146
- title: input.title,
147
- detail: input.detail,
236
+ title: input.title.trim(),
237
+ detail: input.detail.trim(),
148
238
  evidence: input.evidence ?? {},
149
239
  suggested_actions: input.suggested_actions ?? [],
150
- idempotency_key: input.idempotency_key,
240
+ idempotency_key: normalizedKey,
151
241
  })
152
242
  .select('*')
153
243
  .single();
@@ -157,17 +247,21 @@ async function submitContextEvent(input) {
157
247
  .select('*')
158
248
  .eq('project_id', projectId)
159
249
  .eq('source', 'synchronex')
160
- .eq('idempotency_key', input.idempotency_key)
250
+ .eq('idempotency_key', normalizedKey)
161
251
  .maybeSingle();
162
252
  if (duplicate.error)
163
- return { ...dbToolError(duplicate.error) };
164
- if (duplicate.data)
165
- return { context_event: duplicate.data, work_item: null, proposals: [], idempotent: true };
253
+ return toolError(dbToolError(duplicate.error));
254
+ if (duplicate.data) {
255
+ if (!isSameContextEventPayload(duplicate.data, input)) {
256
+ return toolError({ code: 'conflict', error: 'idempotency_key was already used with different content' });
257
+ }
258
+ return buildContextReplay(projectId, duplicate.data);
259
+ }
166
260
  }
167
261
  if (inserted.error)
168
- return { ...dbToolError(inserted.error) };
262
+ return toolError(dbToolError(inserted.error));
169
263
  const event = inserted.data;
170
- const segmentAction = segmentActionFromContext(input.event_type, input.suggested_actions, input.title, input.detail);
264
+ const segmentAction = segmentActionFromContext(input.event_type, input.suggested_actions, input.title.trim(), input.detail.trim());
171
265
  const proposals = [];
172
266
  if (segmentAction) {
173
267
  const targetId = stableUuid(`synchronex-context:${projectId}:${event.id}:segment`);
@@ -190,7 +284,7 @@ async function submitContextEvent(input) {
190
284
  sample_contact_ids: [],
191
285
  recommendation_kind: 'synchronex_context',
192
286
  },
193
- reasoning: input.detail,
287
+ reasoning: input.detail.trim(),
194
288
  source_data_refs: [{ table: 'email.ai_context_events', ids: [event.id] }],
195
289
  confidence: typeof input.evidence?.confidence === 'number' ? Math.max(0, Math.min(100, Math.trunc(input.evidence.confidence))) : 75,
196
290
  status: 'pending',
@@ -201,7 +295,7 @@ async function submitContextEvent(input) {
201
295
  .select(proposalSelect)
202
296
  .single();
203
297
  if (error)
204
- return { ...dbToolError(error) };
298
+ return toolError(dbToolError(error));
205
299
  proposals.push(proposal);
206
300
  }
207
301
  const proposalIds = proposals.map((proposal) => proposal.id);
@@ -215,21 +309,21 @@ async function submitContextEvent(input) {
215
309
  severity: input.event_type === 'risk_signal' ? 'medium' : 'info',
216
310
  confidence: typeof input.evidence?.confidence === 'number' ? Math.max(0, Math.min(100, Math.trunc(input.evidence.confidence))) : null,
217
311
  source: 'synchronex',
218
- title: input.title,
219
- why_now: input.detail,
220
- evidence: { ...(input.evidence ?? {}), context_event_id: event.id, synchronex_workspace_id: input.workspace_id, event_type: input.event_type },
312
+ title: input.title.trim(),
313
+ why_now: input.detail.trim(),
314
+ evidence: { ...(input.evidence ?? {}), context_event_id: event.id, synchronex_workspace_id: input.workspace_id.trim(), event_type: input.event_type },
221
315
  recommended_actions: proposalIds.length
222
316
  ? proposalIds.map((proposalId) => ({ type: 'review_proposal', label: 'Review recommended segment', proposal_id: proposalId, risk: 'low' }))
223
317
  : [{ type: 'review_context', label: 'Review Synchronex context', risk: 'low' }],
224
318
  surfaces: proposalIds.length ? ['overview', 'segments'] : ['overview'],
225
319
  proposal_ids: proposalIds,
226
- dedupe_key: `synchronex:${input.workspace_id}:${input.idempotency_key}`,
320
+ dedupe_key: `synchronex:${input.workspace_id.trim()}:${normalizedKey}`,
227
321
  cooldown_until: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
228
322
  })
229
323
  .select(workItemSelect)
230
324
  .single();
231
325
  if (workError)
232
- return { ...dbToolError(workError) };
326
+ return toolError(dbToolError(workError));
233
327
  const { data: updatedEvent, error: updateError } = await db()
234
328
  .from('ai_context_events')
235
329
  .update({ work_item_id: workItem.id, proposal_ids: proposalIds })
@@ -238,8 +332,8 @@ async function submitContextEvent(input) {
238
332
  .select('*')
239
333
  .single();
240
334
  if (updateError)
241
- return { ...dbToolError(updateError) };
242
- return { context_event: updatedEvent, work_item: workItem, proposals, idempotent: false };
335
+ return toolError(dbToolError(updateError));
336
+ return j({ context_event: updatedEvent, work_item: workItem, proposals, idempotent: false });
243
337
  }
244
338
  export function registerAiWorkTools(server) {
245
339
  server.registerTool('list_ai_work_items', {
@@ -257,18 +351,21 @@ export function registerAiWorkTools(server) {
257
351
  limit: z.number().int().min(1).max(100).optional().default(25),
258
352
  },
259
353
  }, async ({ status, surface, limit }) => {
354
+ const parsed = parseStatuses(status);
355
+ if ('error' in parsed)
356
+ return toolError(parsed.error);
260
357
  let query = db()
261
358
  .from('ai_work_items')
262
359
  .select(workItemSelect)
263
360
  .eq('project_id', getProjectId())
264
- .in('status', parseStatuses(status))
361
+ .in('status', parsed.statuses)
265
362
  .order('generated_at', { ascending: false })
266
363
  .limit(limit ?? 25);
267
364
  if (surface)
268
365
  query = query.contains('surfaces', [surface]);
269
366
  const { data, error } = await query;
270
367
  if (error)
271
- return txt(`Error: ${dbToolError(error).error}`);
368
+ return toolError(dbToolError(error));
272
369
  return j({ work_items: data ?? [] });
273
370
  });
274
371
  server.registerTool('get_ai_work_item', {
@@ -291,9 +388,9 @@ export function registerAiWorkTools(server) {
291
388
  .eq('id', work_item_id)
292
389
  .maybeSingle();
293
390
  if (error)
294
- return txt(`Error: ${dbToolError(error).error}`);
391
+ return toolError(dbToolError(error));
295
392
  if (!data)
296
- return txt('AI work item not found.');
393
+ return toolError({ code: 'not_found', error: 'AI work item not found.' });
297
394
  return j({ work_item: data });
298
395
  });
299
396
  server.registerTool('list_recommended_segments', {
@@ -310,16 +407,19 @@ export function registerAiWorkTools(server) {
310
407
  limit: z.number().int().min(1).max(100).optional().default(25),
311
408
  },
312
409
  }, async ({ status, limit }) => {
410
+ const parsed = parseStatuses(status);
411
+ if ('error' in parsed)
412
+ return toolError(parsed.error);
313
413
  const { data: workItems, error } = await db()
314
414
  .from('ai_work_items')
315
415
  .select(workItemSelect)
316
416
  .eq('project_id', getProjectId())
317
417
  .eq('kind', 'segment_recommendation')
318
- .in('status', parseStatuses(status))
418
+ .in('status', parsed.statuses)
319
419
  .order('generated_at', { ascending: false })
320
420
  .limit(limit ?? 25);
321
421
  if (error)
322
- return txt(`Error: ${dbToolError(error).error}`);
422
+ return toolError(dbToolError(error));
323
423
  const proposalIds = [...new Set((workItems ?? []).flatMap((item) => Array.isArray(item.proposal_ids) ? item.proposal_ids : []))];
324
424
  let proposals = [];
325
425
  if (proposalIds.length) {
@@ -329,7 +429,7 @@ export function registerAiWorkTools(server) {
329
429
  .eq('project_id', getProjectId())
330
430
  .in('id', proposalIds);
331
431
  if (proposalResult.error)
332
- return txt(`Error: ${dbToolError(proposalResult.error).error}`);
432
+ return toolError(dbToolError(proposalResult.error));
333
433
  proposals = proposalResult.data ?? [];
334
434
  }
335
435
  return j({ work_items: workItems ?? [], proposals });
@@ -347,8 +447,7 @@ export function registerAiWorkTools(server) {
347
447
  work_item_id: z.string().uuid(),
348
448
  },
349
449
  }, async ({ work_item_id }) => {
350
- const result = await updateWorkItemStatus(work_item_id, 'seen');
351
- return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
450
+ return updateWorkItemStatus(work_item_id, 'seen');
352
451
  });
353
452
  server.registerTool('snooze_ai_work_item', {
354
453
  title: 'Snooze AI Work Item',
@@ -364,8 +463,11 @@ export function registerAiWorkTools(server) {
364
463
  snoozed_until: z.string().datetime(),
365
464
  },
366
465
  }, async ({ work_item_id, snoozed_until }) => {
367
- const result = await updateWorkItemStatus(work_item_id, 'snoozed', snoozed_until);
368
- return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
466
+ const snoozedTime = new Date(snoozed_until).getTime();
467
+ if (!Number.isFinite(snoozedTime) || snoozedTime <= Date.now()) {
468
+ return toolError({ code: 'validation_failed', error: 'snoozed_until must be in the future.' });
469
+ }
470
+ return updateWorkItemStatus(work_item_id, 'snoozed', snoozed_until);
369
471
  });
370
472
  server.registerTool('dismiss_ai_work_item', {
371
473
  title: 'Dismiss AI Work Item',
@@ -380,8 +482,7 @@ export function registerAiWorkTools(server) {
380
482
  work_item_id: z.string().uuid(),
381
483
  },
382
484
  }, async ({ work_item_id }) => {
383
- const result = await updateWorkItemStatus(work_item_id, 'dismissed');
384
- return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
485
+ return updateWorkItemStatus(work_item_id, 'dismissed');
385
486
  });
386
487
  server.registerTool('submit_ai_context_event', {
387
488
  title: 'Submit AI Context Event',
@@ -394,8 +495,7 @@ export function registerAiWorkTools(server) {
394
495
  description: 'Submit Synchronex strategic context into Sendinel AI work queue without directly mutating email state.',
395
496
  inputSchema: contextEventInput,
396
497
  }, async (input) => {
397
- const result = await submitContextEvent({ ...input, source: 'synchronex' });
398
- return 'error' in result ? txt(`Error: ${result.error}`) : j(result);
498
+ return submitContextEvent(input);
399
499
  });
400
500
  server.registerTool('list_agent_proposals', {
401
501
  title: 'List Agent Proposals',
@@ -410,7 +510,7 @@ export function registerAiWorkTools(server) {
410
510
  status: z.enum(['pending', 'applied', 'auto_applied', 'dismissed', 'disputed', 'stale', 'failed', 'expired']).optional(),
411
511
  type: z.string().min(1).max(96).optional(),
412
512
  target_table: z.string().min(1).max(96).optional(),
413
- target_id: z.string().optional(),
513
+ target_id: z.string().uuid().optional(),
414
514
  limit: z.number().int().min(1).max(100).optional().default(25),
415
515
  },
416
516
  }, async ({ status, type, target_table, target_id, limit }) => {
@@ -430,7 +530,7 @@ export function registerAiWorkTools(server) {
430
530
  query = query.eq('target_id', target_id);
431
531
  const { data, error } = await query;
432
532
  if (error)
433
- return txt(`Error: ${dbToolError(error).error}`);
533
+ return toolError(dbToolError(error));
434
534
  return j({ proposals: data ?? [] });
435
535
  });
436
536
  server.registerTool('get_agent_proposal', {
@@ -453,9 +553,9 @@ export function registerAiWorkTools(server) {
453
553
  .eq('id', proposal_id)
454
554
  .maybeSingle();
455
555
  if (error)
456
- return txt(`Error: ${dbToolError(error).error}`);
556
+ return toolError(dbToolError(error));
457
557
  if (!data)
458
- return txt('Agent proposal not found.');
558
+ return toolError({ code: 'not_found', error: 'Agent proposal not found.' });
459
559
  return j({ proposal: data });
460
560
  });
461
561
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.188",
3
+ "version": "1.0.189",
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",