@sendinel/mcp-server 1.0.143 → 1.0.145

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
@@ -18,7 +18,7 @@ preserves REST outcomes, and redacts failures. See the
18
18
  [event-triggered campaign guide](../../docs/event-triggered-campaigns.md) for
19
19
  inputs, idempotency, subscription requirements, and conditional step examples.
20
20
 
21
- Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
21
+ Sendinel currently exposes 35 tool groups and 346 tools (gateway mode exposes 4 meta-tools, sharing how_to with full mode). These counts are checked against the tool registry in CI, so this table matches what ships.
22
22
 
23
23
  | Group | Count | Purpose |
24
24
  |-------|-------|---------|
@@ -27,7 +27,7 @@ Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4
27
27
  | `advisor` | 6 | Campaign advisor: recommendations, benchmarks, subject/content/send-time guidance |
28
28
  | `ai-work` | 9 | AI work items, recommended segments, context events, agent proposals |
29
29
  | `analytics` | 15 | Stats, portfolio analytics, project switching, domain health, engagement, deliverability, revenue analytics |
30
- | `approvals` | 3 | Poll and list risk-tiered action approvals |
30
+ | `approvals` | 5 | Poll/list approvals, person OAuth approval, write-scoped cancellation, opted-in chat confirmation |
31
31
  | `automations` | 7 | Automation triggers, previews, and response-gated escalation ladders |
32
32
  | `brand_kit` | 6 | Brand Core text import, media library upload/URL ingest, asset-context reads, project logo selection |
33
33
  | `campaigns` | 44 | Full campaign lifecycle: create, edit, approve, launch, enroll, schedule, sequence templates, convert imported campaigns |
@@ -58,6 +58,14 @@ Sendinel currently exposes 35 tool groups and 344 tools (gateway mode exposes 4
58
58
  | `warmup` | 4 | IP/domain warmup schedule control |
59
59
  | `webhooks` | 5 | Webhook subscription management |
60
60
 
61
+ `cancel_approval({approval_id})` rejects an unexpired, unused pending/approved
62
+ request in the current project for any write-scoped credential.
63
+ `approve_approval({approval_id})` requires the person's own live OAuth connection
64
+ (`connection_role: NULL`) and its own pending request in that project. Tester,
65
+ agent, API key, service-token and scoped-JWT credentials cannot approve. Sends
66
+ and deletes still need approval; the user may answer through their AI without
67
+ another verification prompt. See [approval tools](../../docs/mcp-tools.md#approve_approval).
68
+
61
69
  ## Install / run
62
70
 
63
71
  ```bash
@@ -405,10 +405,10 @@ export async function checkApproval(approvalId, options = {}) {
405
405
  }
406
406
  if (data.used_at || data.status === 'used')
407
407
  return { approved: false, status: 'consumed' };
408
- // Chat decisions stay bound to the same live human grant at consumption, for every action.
409
- if (data.status === 'approved' && data.decided_via === 'chat_confirm' &&
408
+ // Tool decisions stay bound to the same live human grant at consumption, for every action.
409
+ if (data.status === 'approved' && ['chat_confirm', 'mcp_person'].includes(data.decided_via) &&
410
410
  (requestContext()?.oauthTokenId !== data.oauth_token_id || requestContext()?.actorId !== data.decided_by ||
411
- !await humanApprovalEligible(data.action, getProjectId(), requestContext(), true))) {
411
+ !await humanApprovalEligible(data.action, getProjectId(), requestContext(), data.decided_via === 'chat_confirm'))) {
412
412
  return { approved: false, status: 'human_decision_required' };
413
413
  }
414
414
  const sandboxDecision = data.decided_via === 'sandbox';
@@ -418,7 +418,7 @@ export async function checkApproval(approvalId, options = {}) {
418
418
  }
419
419
  if (data.status === 'approved' && !sandboxDecision && (!isHumanApprovalChannel(data.decided_via) ||
420
420
  (isHumanRequestableAdminTool(data.action) && data.decided_via !== 'dashboard' &&
421
- (!['mcp_app', 'mcp_elicitation', 'chat_confirm'].includes(data.decided_via) ||
421
+ (!['mcp_app', 'mcp_elicitation', 'chat_confirm', 'mcp_person'].includes(data.decided_via) ||
422
422
  !await humanApprovalEligible(data.action, getProjectId(), { projectId: getProjectId(),
423
423
  actorId: data.decided_by, oauthTokenId: data.oauth_token_id, authKind: 'oauth', channelVerified: true }))))) {
424
424
  return { approved: false, status: 'human_decision_required' };
@@ -459,7 +459,7 @@ export async function checkApproval(approvalId, options = {}) {
459
459
  .eq('decided_via', data.decided_via)
460
460
  .eq('params', JSON.stringify(data.params))
461
461
  .gt('requested_at', new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString());
462
- if (data.decided_via === 'chat_confirm')
462
+ if (['chat_confirm', 'mcp_person'].includes(data.decided_via))
463
463
  claimQuery.eq('oauth_token_id', requestContext().oauthTokenId).eq('decided_by', requestContext().actorId);
464
464
  const boundClaim = data.payload_hash ? claimQuery.eq('payload_hash', data.payload_hash) : claimQuery.is('payload_hash', null);
465
465
  const { data: claimed, error: claimError } = await boundClaim.select('decided_by').maybeSingle();
@@ -20,8 +20,10 @@ export const APPROVAL_EXEMPT = {
20
20
  add_suppression: "only stops mail to an address; reduces sending",
21
21
  add_synced_folder: "starts syncing a Drive folder into the project own media library; inbound only",
22
22
  add_white_label_domain: "adds a pending branding domain record; reversible and sends nothing",
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",
23
24
  approve_data_proposal: "turns a proposal into an AI draft for review; sends nothing",
24
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",
25
27
  cancel_send_queue_rows: "cancels queued mail before it sends; reduces sending",
26
28
  clone_campaign: "creates a draft copy of a campaign; nothing sends until activated (gated)",
27
29
  clone_sequence_template: "creates a draft campaign from a template; nothing sends until activated (gated)",
@@ -0,0 +1,15 @@
1
+ export declare function cancelApproval(approvalId: string, transportClientName?: string): Promise<{
2
+ code: string;
3
+ error: string;
4
+ } | {
5
+ approval_id: string;
6
+ status: 'rejected';
7
+ }>;
8
+ export declare function approveApproval(approvalId: string, transportClientName?: string): Promise<{
9
+ code: string;
10
+ error: string;
11
+ } | {
12
+ approval_id: string;
13
+ decision_status: 'approved';
14
+ next: string;
15
+ }>;
@@ -0,0 +1,103 @@
1
+ import { requestContext } from '../db.js';
2
+ import { getProjectId } from '../project.js';
3
+ import { humanApprovalEligible } from './approval-human-eligibility.js';
4
+ import { approvalStore } from './tester-requests-store.js';
5
+ const refuse = (code, error) => ({ code, error });
6
+ const store = () => approvalStore().schema('email');
7
+ /** OAuth names come from the registered client, never a model-supplied argument. */
8
+ async function clientName(fallback) {
9
+ const actor = requestContext();
10
+ if (!actor?.oauthTokenId)
11
+ return fallback ?? actor?.agentLabel ?? null;
12
+ const client = store();
13
+ const { data: grant, error: grantError } = await client.from('mcp_oauth_tokens')
14
+ .select('client_id').eq('id', actor.oauthTokenId).maybeSingle();
15
+ if (grantError || !grant?.client_id)
16
+ throw new Error('Unable to identify the OAuth client');
17
+ const { data, error } = await client.from('mcp_oauth_clients')
18
+ .select('client_name').eq('client_id', grant.client_id).maybeSingle();
19
+ if (error || typeof data?.client_name !== 'string')
20
+ throw new Error('Unable to identify the OAuth client');
21
+ return data.client_name;
22
+ }
23
+ export async function cancelApproval(approvalId, transportClientName) {
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.');
27
+ }
28
+ try {
29
+ const projectId = getProjectId();
30
+ const name = await clientName(transportClientName);
31
+ const now = new Date().toISOString();
32
+ // A single conditional write arbitrates cancellation versus approval/consumption.
33
+ // Preserve the original decision provenance; cancellation never grants authority.
34
+ const { data, error } = await store().from('action_approvals')
35
+ .update({ status: 'rejected' })
36
+ .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
+ if (error)
40
+ return refuse('approval_update_failed', 'Unable to cancel the approval. Check its status before retrying.');
41
+ if (!data)
42
+ return refuse('approval_not_cancellable', 'No unexpired, unused pending or approved approval found in this project.');
43
+ const { error: auditError } = await store().from('audit_log').insert({
44
+ project_id: projectId, actor_type: actor.actorType ?? actor.authKind ?? 'api_key',
45
+ actor_id: actor.actorId ?? actor.keyId ?? actor.oauthTokenId ?? null,
46
+ action: 'approval.cancelled', resource_type: 'approval', resource_id: approvalId,
47
+ details: { status: 'rejected', client_name: name, auth_kind: actor.authKind ?? null,
48
+ oauth_token_id: actor.oauthTokenId ?? null, api_key_id: actor.keyId ?? null },
49
+ });
50
+ if (auditError)
51
+ return refuse('approval_audit_failed', 'Approval cancelled, but audit failed. Check its status before retrying.');
52
+ return { approval_id: approvalId, status: 'rejected' };
53
+ }
54
+ catch {
55
+ return refuse('approval_failed', 'Cancellation failed. Check approval status before retrying.');
56
+ }
57
+ }
58
+ export async function approveApproval(approvalId, transportClientName) {
59
+ const actor = requestContext();
60
+ const projectId = getProjectId();
61
+ // Reuse the same live-grant identification as the app/elicitation path (#4312).
62
+ if (!await humanApprovalEligible('approve_approval', projectId, actor)) {
63
+ return refuse('approval_forbidden', 'Approval requires your own verified person-role OAuth connection in this project.');
64
+ }
65
+ try {
66
+ const client = store();
67
+ const { data: approval, error: loadError } = await client.from('action_approvals')
68
+ .select('action, params, payload_hash')
69
+ .eq('id', approvalId).eq('project_id', projectId).eq('oauth_token_id', actor.oauthTokenId)
70
+ .eq('status', 'pending').is('used_at', null).gt('expires_at', new Date().toISOString()).maybeSingle();
71
+ if (loadError)
72
+ return refuse('approval_lookup_failed', 'Unable to verify the approval. Nothing was recorded.');
73
+ if (!approval)
74
+ return refuse('approval_not_pending', 'No unexpired, unused pending approval found for this connection and project.');
75
+ if (!await humanApprovalEligible(approval.action, projectId, actor)) {
76
+ return refuse('approval_forbidden', 'This approval requires a person with the required project role.');
77
+ }
78
+ const name = await clientName(transportClientName);
79
+ const now = new Date().toISOString();
80
+ const decision = client.from('action_approvals')
81
+ .update({ status: 'approved', decided_via: 'mcp_person', decided_by: actor.actorId, decided_at: now })
82
+ .eq('id', approvalId).eq('project_id', projectId).eq('oauth_token_id', actor.oauthTokenId)
83
+ .eq('action', approval.action).eq('status', 'pending').is('used_at', null).gt('expires_at', now)
84
+ .eq('params', JSON.stringify(approval.params));
85
+ const bound = approval.payload_hash == null ? decision.is('payload_hash', null) : decision.eq('payload_hash', approval.payload_hash);
86
+ const { data, error } = await bound.select('id, payload_hash').maybeSingle();
87
+ if (error || !data)
88
+ return refuse('approval_not_pending', 'Approval is already decided, used or no longer valid. Request a new approval.');
89
+ const { error: auditError } = await client.from('audit_log').insert({
90
+ project_id: projectId, actor_type: 'user', actor_id: actor.actorId,
91
+ action: 'approval.submitted', resource_type: 'approval', resource_id: approvalId,
92
+ details: { status: 'approved', channel: 'mcp_person', client_name: name,
93
+ connection_id: `oauth:${actor.oauthTokenId}`, payload_hash: data.payload_hash },
94
+ });
95
+ if (auditError)
96
+ return refuse('approval_audit_failed', 'Approval recorded, but audit failed. Check its status before retrying.');
97
+ return { approval_id: approvalId, decision_status: 'approved',
98
+ next: `Call ${approval.action} again with the same arguments plus approval_id.` };
99
+ }
100
+ catch {
101
+ return refuse('approval_failed', 'Approval failed. Check approval status before retrying.');
102
+ }
103
+ }
package/build/server.js CHANGED
@@ -87,6 +87,7 @@ export const ALL_TOOL_GROUPS = Object.keys(GROUP_REGISTRY);
87
87
  // ── Scope enforcement ─────────────────────────────────────────────────────
88
88
  // Maps tool names to required scope. Mirrors dashboard's WRITE_TOOLS/ADMIN_TOOLS.
89
89
  const WRITE_TOOLS = new Set([
90
+ 'cancel_approval', 'approve_approval',
90
91
  'recheck_domain_dns',
91
92
  'mark_agent_message', 'draft_agent_reply', 'create_agent_mailbox',
92
93
  'add_subscriber', 'identify_subscriber', 'emit_contact_event', 'import_subscribers', 'update_subscriber', 'bulk_update_subscribers',
@@ -172,7 +173,7 @@ export function isAgentConnection(scopes) {
172
173
  return !!scopes?.includes(AGENT_CONNECTION_SCOPE);
173
174
  }
174
175
  export function agentConnectionAllowsTool(name) {
175
- return ['list_agent_inbox', 'read_agent_message', 'get_agent_thread', 'mark_agent_message',
176
+ return ['cancel_approval', 'list_agent_inbox', 'read_agent_message', 'get_agent_thread', 'mark_agent_message',
176
177
  'draft_agent_reply', 'search_tools', 'describe_tool', 'invoke_tool'].includes(name);
177
178
  }
178
179
  export function agentConnectionRefusal(name) {
@@ -186,6 +187,7 @@ export function testerConnectionAllowsTool(name) {
186
187
  // Retain the existing campaign activation path. A globally gated tool is not by itself
187
188
  // permission for a tester: sends, deletes and access/infrastructure writes stay refused.
188
189
  return getRequiredScope(name) === 'read'
190
+ || name === 'cancel_approval'
189
191
  || TESTER_REQUEST_TOOLS.has(name)
190
192
  || name === 'activate_campaigns'
191
193
  || name === 'update_campaign_status';
@@ -321,7 +323,7 @@ function createScopeProxy(server, scopes, toolGroup, allowedTools) {
321
323
  const required = getRequiredScope(name);
322
324
  const tester = isTesterConnection(scopes);
323
325
  const agent = isAgentConnection(scopes);
324
- const allowed = () => agent ? agentConnectionAllowsTool(name) : tester
326
+ const allowed = () => name === 'cancel_approval' ? scopeAllows(scopes, 'write') : agent ? agentConnectionAllowsTool(name) : tester
325
327
  ? testerConnectionAllowsTool(name)
326
328
  : scopeAllows(scopes, required) || (scopeAllows(scopes, 'read') && isHumanRequestableAdminTool(name));
327
329
  if (!allowed()) {
@@ -4,9 +4,29 @@ import { getProjectId } from '../project.js';
4
4
  import { dbToolError, toolError } from '../lib/db-error.js';
5
5
  import { checkApproval } from '../lib/action-approvals.js';
6
6
  import { confirmApproval, confirmApprovalInput } from '../lib/approval-chat-confirm.js';
7
+ import { approveApproval, cancelApproval } from '../lib/approval-tool-decisions.js';
8
+ import { approvalClientName } from '../lib/approval-capabilities.js';
7
9
  const txt = (text) => ({ content: [{ type: 'text', text }] });
8
10
  const j = (data) => txt(JSON.stringify(data, null, 2));
9
11
  export function registerApprovalTools(server, options) {
12
+ server.registerTool('cancel_approval', {
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.',
15
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
16
+ inputSchema: { approval_id: z.string().uuid() },
17
+ }, async ({ approval_id }) => {
18
+ const result = await cancelApproval(approval_id, server.server && approvalClientName(server.server));
19
+ return { ...j(result), ...('code' in result ? { isError: true } : {}) };
20
+ });
21
+ server.registerTool('approve_approval', {
22
+ title: 'Approve Approval',
23
+ description: "Record the person's approval through their own OAuth connection for this project. Only person-role OAuth connections may approve their own pending, unexpired, unused requests. Tester, agent, API key, service token and scoped JWT credentials cannot approve. Call only when the person asks to approve; then retry the original action with approval_id.",
24
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
25
+ inputSchema: { approval_id: z.string().uuid() },
26
+ }, async ({ approval_id }) => {
27
+ const result = await approveApproval(approval_id, server.server && approvalClientName(server.server));
28
+ return { ...j(result), ...('code' in result ? { isError: true } : {}) };
29
+ });
10
30
  if (options?.chatConfirmEnabled !== false && (!options?.scopes || options.chatConfirmEnabled === true) &&
11
31
  !options?.scopes?.some(scope => scope === 'tester' || scope === 'agent')) {
12
32
  server.registerTool('confirm_approval', {
@@ -92,7 +92,11 @@ export function buildCampaignFiresOn(campaign) {
92
92
  parts.push(`event${events.length > 1 ? 's' : ''} ${events.map((e) => `"${e}"`).join(', ')} sent to /v1/events`);
93
93
  if (tags.length)
94
94
  parts.push(`tag${tags.length > 1 ? 's' : ''} ${tags.map((t) => `"${t}"`).join(', ')} added`);
95
- if (campaign.segment_id)
95
+ if (campaign.type === 'broadcast') {
96
+ parts.length = 0;
97
+ parts.push(campaign.send_at ? `the scheduled send time (${campaign.send_at})` : 'nothing scheduled (no send_at set)');
98
+ }
99
+ else if (campaign.segment_id)
96
100
  parts.push('contacts entering its segment');
97
101
  let summary = parts.length ? `Fires on: ${parts.join('; ')}.` : 'Fires on: nothing (no trigger set).';
98
102
  const warnings = [];
@@ -731,7 +735,7 @@ export function registerCampaignTools(server) {
731
735
  }, async ({ site_id, status, limit, period }) => {
732
736
  let q = db()
733
737
  .from('campaigns')
734
- .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, unsub_footer_template, requires_approval, approved_by, approved_at')
738
+ .select('id, site_id, name, type, status, sends_per_hour, total_enrolled, total_sent, total_opened, total_clicked, created_at, period_key, segment_id, trigger_type, trigger_event_name, trigger_tag, trigger_config, send_class, unsub_footer_template, requires_approval, approved_by, approved_at, send_at')
735
739
  .eq('project_id', getProjectId())
736
740
  .order('created_at', { ascending: false })
737
741
  .limit(limit ?? 25);
@@ -2956,14 +2960,16 @@ Do not include body_html in your response; Sendinel renders body_html from block
2956
2960
  }, async ({ html, site_id, custom_variables, subject, body_text, trigger_event_name, event_keys }) => {
2957
2961
  const projectId = getProjectId();
2958
2962
  let voiceConfig = null;
2963
+ let autoUnsubscribeFooter = true;
2959
2964
  if (site_id) {
2960
2965
  const { data: site } = await db()
2961
2966
  .from('sites')
2962
- .select('brand_voice')
2967
+ .select('brand_voice, unsub_link_enabled')
2963
2968
  .eq('id', site_id)
2964
2969
  .eq('project_id', projectId)
2965
2970
  .maybeSingle();
2966
2971
  voiceConfig = site?.brand_voice ?? null;
2972
+ autoUnsubscribeFooter = site?.unsub_link_enabled !== false;
2967
2973
  }
2968
2974
  // The brand kit's sign-off name applies to every site in the project (#3854).
2969
2975
  const { data: projectRow } = await db().from('projects').select('brand_settings').eq('id', projectId).maybeSingle();
@@ -2973,16 +2979,19 @@ Do not include body_html in your response; Sendinel renders body_html from block
2973
2979
  .filter((row) => !site_id || row.id === site_id)
2974
2980
  .flatMap((row) => [sendingHostOf(row.sending_domain), sendingHostOf(row.from_email)])
2975
2981
  .filter((host) => Boolean(host));
2982
+ const contacts = await contactPropertyKeys(projectId);
2983
+ const { data: definitions } = await db().from('custom_field_definitions').select('key').eq('project_id', projectId);
2984
+ const contactVariables = [...(contacts?.keys ?? []), ...(definitions ?? []).map((field) => field.key)].map((key) => `custom.${key}`);
2976
2985
  let sourcedKeys;
2977
2986
  if (trigger_event_name || event_keys) {
2978
2987
  const fromHistory = trigger_event_name ? await eventKeysFromHistory(projectId, trigger_event_name) : [];
2979
- const contacts = await contactPropertyKeys(projectId);
2980
2988
  sourcedKeys = fromHistory === null || contacts === null
2981
2989
  ? null
2982
- : [...(event_keys ?? []), ...fromHistory, ...contacts.keys.map((key) => `custom.${key}`)];
2990
+ : [...(event_keys ?? []), ...fromHistory, ...contactVariables];
2983
2991
  }
2984
2992
  const result = validateTemplate(html, voiceConfig, {
2985
- customVariables: [...(custom_variables ?? []), ...(sourcedKeys ?? [])],
2993
+ customVariables: [...(custom_variables ?? []), ...contactVariables, ...(sourcedKeys ?? [])],
2994
+ autoUnsubscribeFooter,
2986
2995
  subject,
2987
2996
  bodyText: body_text,
2988
2997
  brandSettings: projectRow?.brand_settings ?? null,
@@ -3004,7 +3013,7 @@ Do not include body_html in your response; Sendinel renders body_html from block
3004
3013
  else if (!result.errors.some((error) => error.includes(issue.excerpt)))
3005
3014
  result.warnings.push(issue.message);
3006
3015
  }
3007
- return j({ ...result, valid: result.errors.length === 0 });
3016
+ return j({ ...result, valid: result.errors.length === 0, ...(result.errors.length ? { code: 'validation_failed' } : {}) });
3008
3017
  });
3009
3018
  server.registerTool('check_content_consistency', {
3010
3019
  title: 'Check Content Consistency',
@@ -493,8 +493,8 @@ function primaryContactValue(value) {
493
493
  }
494
494
  // #3623: last_transactional_at / total_transactional_sent are service (transactional) mail, tracked apart
495
495
  // from the marketing last_emailed_at / total_emails_sent. NULL means none recorded, not "never".
496
- const SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
497
- const SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage';
496
+ const SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage, tags';
497
+ const SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, global_unsubscribed, source, created_at, last_emailed_at, total_emails_sent, last_transactional_at, total_transactional_sent, total_opens, total_clicks, engagement_score, lifecycle_stage, tags';
498
498
  const SEGMENT_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
499
499
  const SEGMENT_SUBSCRIBER_SELECT_NO_COMPANY = 'id, email, first_name, last_name, company_id, engagement_score, lifecycle_stage, last_opened_at, last_clicked_at, total_opens, total_clicks';
500
500
  const UPDATE_SUBSCRIBER_SELECT_WITH_COMPANY = 'id, email, first_name, last_name, company_id, company:companies(id, name, domain), custom_properties, tags, phone_e164, timezone';
@@ -962,7 +962,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
962
962
  idempotentHint: true,
963
963
  openWorldHint: false,
964
964
  },
965
- description: 'List contacts, optionally filtered by site, search term, and/or a saved subscriber view. Explicit arguments and saved-view filters are combined to narrow the result.',
965
+ description: 'List contacts including tags, optionally filtered by site, search term, and/or a saved subscriber view. Explicit arguments and saved-view filters are combined to narrow the result.',
966
966
  inputSchema: {
967
967
  site_id: z.string().uuid().optional(),
968
968
  company_id: z.string().uuid().optional(),
@@ -7,9 +7,9 @@ import { getRegisteredToolGroups, registerAllTools } from '../server.js';
7
7
  import { rankTools, scoreTool, TOOL_ALIASES, tokenize } from './tool-search.js';
8
8
  const textResult = (text) => ({ content: [{ type: 'text', text }] });
9
9
  const jsonResult = (data) => textResult(JSON.stringify(data, null, 2));
10
- const notFound = (message) => ({
10
+ const notFound = (message, code = 'not_found') => ({
11
11
  isError: true,
12
- content: [{ type: 'text', text: message }],
12
+ ...jsonResult({ error: message, code }),
13
13
  });
14
14
  /**
15
15
  * Plain words people use for a tool (#3669, #4059). The map lives in tool-search.ts so how_to can
@@ -49,7 +49,7 @@ async function buildCatalog(options) {
49
49
  function unknownKeyMessage(keys) {
50
50
  const list = keys.map((k) => `'${k}'`).join(', ');
51
51
  const hinted = keys.some((k) => ARGS_LOOKALIKES.has(k.toLowerCase()));
52
- return `Unknown key(s) on invoke_tool: ${list}${hinted ? "; did you mean 'args'?" : ''} Valid keys: name, args (alias: arguments).`;
52
+ return `Unknown key(s) on invoke_tool: ${list}${hinted ? "; did you mean 'args'?" : ''} Valid keys: name, args (alias: arguments), project_id.`;
53
53
  }
54
54
  const ARGS_LOOKALIKES = new Set(['args', 'argument', 'params', 'input', 'parameters']);
55
55
  function declaredProperties(inputSchema) {
@@ -83,20 +83,28 @@ export function registerGatewayTools(server, options) {
83
83
  const getCatalog = () => (catalogPromise ??= buildCatalog(options));
84
84
  server.registerTool('search_tools', {
85
85
  title: 'Search Tools',
86
- description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Call describe_tool for the full input schema before invoke_tool.',
86
+ description: 'Search the permitted Sendinel MCP tool catalog by keyword and/or group. Returns name, group, and description. Optional limit/offset page ranked search results or explicit browsing. Call describe_tool for the full input schema before invoke_tool.',
87
87
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
88
88
  inputSchema: {
89
89
  query: z.string().optional().describe('Words to match against tool name, group, description, or common phrasing (for example "send email")'),
90
90
  group: z.string().optional().describe('Restrict results to one tool group, for example campaigns'),
91
+ limit: z.number().int().min(1).max(50).optional().describe('Page size (1–50); ranked search defaults to 10, browsing stays uncapped unless paging is supplied'),
92
+ offset: z.number().int().min(0).optional().describe('Zero-based result offset (default 0)'),
91
93
  },
92
- }, async ({ query, group }) => {
94
+ }, async ({ query, group, limit, offset }) => {
93
95
  const catalog = await getCatalog();
94
96
  const q = query?.trim().toLowerCase() ?? '';
95
97
  const requestedGroup = group?.trim();
96
98
  const scoped = catalog.entries.filter((tool) => !requestedGroup || tool.group === requestedGroup);
97
99
  const summarize = ({ name, group, description }) => ({ name, group, description });
100
+ const start = offset ?? 0;
101
+ const pageSize = limit ?? SEARCH_LIMIT;
98
102
  if (!q) {
99
- return jsonResult({ tools: scoped.map(summarize), total: scoped.length });
103
+ if (limit === undefined && offset === undefined)
104
+ return jsonResult({ tools: scoped.map(summarize), total: scoped.length });
105
+ const page = scoped.slice(start, start + pageSize);
106
+ const truncated = start + page.length < scoped.length;
107
+ return jsonResult({ tools: page.map(summarize), total: scoped.length, truncated, ...(truncated ? { next_offset: start + page.length } : {}) });
100
108
  }
101
109
  const { matches, nearest } = rankTools(scoped, q);
102
110
  if (matches.length === 0) {
@@ -108,12 +116,14 @@ export function registerGatewayTools(server, options) {
108
116
  hint: 'No tool matched that phrasing. The nearest tools are listed; try a shorter keyword, or browse with the group filter.',
109
117
  });
110
118
  }
111
- const top = matches.slice(0, SEARCH_LIMIT);
119
+ const top = matches.slice(start, start + pageSize);
120
+ const truncated = start + top.length < matches.length;
112
121
  return jsonResult({
113
122
  tools: top.map(({ entry }) => summarize(entry)),
114
123
  total: matches.length,
115
124
  ranked: true,
116
- ...(matches.length > top.length ? { truncated: true } : {}),
125
+ truncated,
126
+ ...(truncated ? { next_offset: start + top.length } : {}),
117
127
  });
118
128
  });
119
129
  server.registerTool('describe_tool', {
@@ -139,15 +149,16 @@ export function registerGatewayTools(server, options) {
139
149
  name: z.string().min(1).describe('Tool name to invoke'),
140
150
  args: z.record(z.string(), z.unknown()).optional().describe('Arguments for the target tool'),
141
151
  arguments: z.record(z.string(), z.unknown()).optional().describe('Alias for args (the MCP-standard field name). Provide args or arguments, not both.'),
152
+ project_id: z.string().uuid().optional().describe('Per-call project override, same as on any tool. The hosted transport selects and strips it before invocation; direct stdio calls use the configured project.'),
142
153
  }),
143
154
  }, async (input) => {
144
- const { name, args, arguments: argumentsAlias, ...rest } = input;
155
+ const { name, args, arguments: argumentsAlias, project_id: _projectId, ...rest } = input;
145
156
  const unknownKeys = Object.keys(rest);
146
157
  if (unknownKeys.length > 0) {
147
- return notFound(unknownKeyMessage(unknownKeys));
158
+ return notFound(unknownKeyMessage(unknownKeys), 'validation_failed');
148
159
  }
149
160
  if (args !== undefined && argumentsAlias !== undefined && JSON.stringify(args) !== JSON.stringify(argumentsAlias)) {
150
- return notFound("Both 'args' and 'arguments' were provided with different values. Provide either 'args' or 'arguments', not both.");
161
+ return notFound("Both 'args' and 'arguments' were provided with different values. Provide either 'args' or 'arguments', not both.", 'validation_failed');
151
162
  }
152
163
  const effective = args ?? argumentsAlias ?? {};
153
164
  const catalog = await getCatalog();
@@ -157,7 +168,7 @@ export function registerGatewayTools(server, options) {
157
168
  }
158
169
  const unknownArgs = findUnknownArgKeys(entry.inputSchema, effective);
159
170
  if (unknownArgs.length > 0) {
160
- return notFound(unknownArgsMessage(name, entry.inputSchema, unknownArgs));
171
+ return notFound(unknownArgsMessage(name, entry.inputSchema, unknownArgs), 'validation_failed');
161
172
  }
162
173
  return await catalog.client.callTool({ name, arguments: effective });
163
174
  });
@@ -297,7 +297,7 @@ export function registerSegmentTools(server) {
297
297
  model: getAnthropicModelId(),
298
298
  max_tokens: AI_MAX_TOKENS,
299
299
  ...outputConfig("low"),
300
- system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at, email_sends. email_sends uses eq only and value {"min_count":4,"within_days":30}; both required, at most one per segment. It counts email_log rows with non-null sent_at in the inclusive elapsed-day window through now, regardless of current status. No observed sends = zero; failed history reads are errors. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
300
+ system: 'You translate natural language audience descriptions into a JSON segment rule tree. Return ONLY valid JSON. Format: {"op":"AND"|"OR","conditions":[conditionOrGroup],"exclude_suppressed":true}. Condition format: {"field": string, "operator":"eq|neq|gt|gte|lt|lte|contains|exists|not_exists|after|before|on_or_after|on_or_before","value": any}. Supported fields: lifecycle_stage, engagement_score, source, created_at, last_opened_at, last_clicked_at, introduced_by_subscriber_id, tags, custom_properties.<key>, payment.plan, payment.mrr_cents, payment.ltv_cents, payment.subscription_status, payment.trial_ends_at, payment.churned_at, payment.paid_at, email_sends. email_sends uses eq only and value {"min_count":4,"within_days":30}; both required, at most one per segment. It counts email_log rows with non-null sent_at in the inclusive elapsed-day window through now, regardless of current status. No observed sends = zero; failed history reads are errors. "tagged A and B AND never opened" means {"op":"AND","conditions":[{"field":"tags","operator":"contains","value":"A"},{"field":"tags","operator":"contains","value":"B"},{"field":"last_opened_at","operator":"not_exists"}],"exclude_suppressed":true}. Use one tags contains condition per required tag; never opened means last_opened_at not_exists (no value), not an unsupported activity field. Example: {"op":"AND","conditions":[{"field":"lifecycle_stage","operator":"eq","value":"trial"},{"field":"payment.trial_ends_at","operator":"on_or_before","value":"' + new Date(Date.now() + 7 * 86400000).toISOString() + '"}]}. Today is ' + new Date().toISOString().slice(0, 10) + '.',
301
301
  messages: [{ role: 'user', content: description }],
302
302
  });
303
303
  const raw = anthropicCheckedText(resp);
@@ -308,8 +308,8 @@ export function registerSegmentTools(server) {
308
308
  catch (parseError) {
309
309
  // #4270: parsed rules that are missing or malformed are an error, never "everyone".
310
310
  if (isSegmentRulesError(parseError))
311
- return txt(`Error: Could not use the rules parsed from "${description}". ${parseError.message}`);
312
- return txt(`Error: Failed to parse rules from "${description}"`);
311
+ return toolError({ error: `Could not use the rules parsed from "${description}". ${parseError.message}`, code: 'validation_failed' });
312
+ return toolError({ error: `Failed to parse rules from "${description}"`, code: 'validation_failed' });
313
313
  }
314
314
  const ids = await getSegmentContactIds(parsedRules);
315
315
  const sample = ids.slice(0, 5);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.143",
3
+ "version": "1.0.145",
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",