@sendinel/mcp-server 1.0.214 → 1.0.216
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 +4 -4
- package/build/lib/action-approvals.d.ts +2 -0
- package/build/lib/action-approvals.js +6 -3
- package/build/lib/approval-actions.js +5 -0
- package/build/lib/how-to-recipes.js +12 -0
- package/build/lib/mcp-created-items.d.ts +3 -0
- package/build/lib/mcp-created-items.js +25 -16
- package/build/lib/sandbox-approvals.d.ts +8 -0
- package/build/lib/sandbox-approvals.js +35 -20
- package/build/lib/survey-link-resolver.js +1 -0
- package/build/server.js +3 -3
- package/build/tools/contacts.js +6 -3
- package/build/tools/content.js +42 -0
- package/build/tools/forms.js +107 -16
- package/build/tools/gdpr.js +1 -1
- package/build/tools/short-links.js +39 -1
- package/build/tools/tool-search.js +1 -0
- package/package.json +1 -1
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
|
|
21
|
+
Sendinel currently exposes 35 tool groups and 356 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
|
|-------|-------|---------|
|
|
@@ -35,19 +35,19 @@ Sendinel currently exposes 35 tool groups and 352 tools (gateway mode exposes 4
|
|
|
35
35
|
| `compound` | 4 | Higher-level campaign/template/site setup and delivery diagnosis workflows |
|
|
36
36
|
| `connector` | 6 | BYOD connector status, table discovery, schema/view setup, mapping, resync |
|
|
37
37
|
| `contacts` | 40 | Subscriber CRUD, saved views, tags, email log, suppression, merge, timeline |
|
|
38
|
-
| `content` |
|
|
38
|
+
| `content` | 15 | Content blocks, gated asset links, blog ingest and auto-publish, sources |
|
|
39
39
|
| `data` | 19 | Platform connectors, imports, API keys, migration jobs, notifications, tenant export |
|
|
40
40
|
| `data-proposals` | 3 | Data-derived campaign proposal generation, listing, approval |
|
|
41
41
|
| `delivery-ops` | 25 | Domains, DNSBL, deliverability diagnostics, queue, DLQ, exports, blackout dates, abuse overrides |
|
|
42
42
|
| `drafts` | 6 | Draft create/approve/reject and draft-to-campaign promotion |
|
|
43
|
-
| `forms` |
|
|
43
|
+
| `forms` | 5 | Form creation, listing, stats |
|
|
44
44
|
| `gdpr` | 4 | Contact data deletion and GDPR log |
|
|
45
45
|
| `landing-pages` | 5 | Landing page CRUD |
|
|
46
46
|
| `lists` | 7 | Static contact lists and membership management |
|
|
47
47
|
| `newsletters` | 12 | Newsletter publications, issues, subscribers, scheduling, sending, stats |
|
|
48
48
|
| `org` | 24 | Team members, plan usage/limits, audit log, model registry, white-label, support tickets, outbound digest, provider switching |
|
|
49
49
|
| `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
|
|
50
|
-
| `short-links` |
|
|
50
|
+
| `short-links` | 4 | Short links, click stats, QR codes |
|
|
51
51
|
| `signatures` | 7 | Sending signature blocks |
|
|
52
52
|
| `sites` | 7 | Site, sender, and brand voice management |
|
|
53
53
|
| `skills` | 3 | Operator skills catalog |
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { DecisionEvidence } from './approval-elicitation-evidence.js';
|
|
2
|
+
import { type AutoApproveUnavailableReason } from './sandbox-approvals.js';
|
|
2
3
|
export { isApprovalGatedAction } from './approval-actions.js';
|
|
3
4
|
/**
|
|
4
5
|
* Action approval tiers:
|
|
@@ -45,6 +46,7 @@ export declare function approvalExcerpt(html: unknown, max?: number): string;
|
|
|
45
46
|
export declare function buildApprovalSummary(action: string, params: Record<string, unknown>, display?: ApprovalDisplay): string;
|
|
46
47
|
export interface ApprovalResult {
|
|
47
48
|
requires_approval: true;
|
|
49
|
+
auto_approve_unavailable_reason?: AutoApproveUnavailableReason;
|
|
48
50
|
approval_id: string;
|
|
49
51
|
tier: ApprovalTier;
|
|
50
52
|
action: string;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { effectiveApproval, auditApprovalLevel } from './approval-classes.js';
|
|
2
|
-
import { sandboxApprovalEligible,
|
|
2
|
+
import { sandboxApprovalEligible, trySandboxApprovalWithReason } from './sandbox-approvals.js';
|
|
3
3
|
import { humanApprovalEligible } from './approval-human-eligibility.js';
|
|
4
4
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
5
5
|
import { z } from 'zod';
|
|
@@ -392,7 +392,8 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
392
392
|
throw new Error(`Failed to create approval request: ${error.message}`);
|
|
393
393
|
let projectName;
|
|
394
394
|
let sandboxDisplayRead = false;
|
|
395
|
-
const
|
|
395
|
+
const sandboxPolicyBlocked = effective.applied && (effective.level === 'ask_every_time' || (effective.class === 'F' && !action.startsWith('delete_')));
|
|
396
|
+
const sandboxAttempt = sandboxPolicyBlocked ? { approved: false, reason: 'approval_policy' } : await trySandboxApprovalWithReason(data.id, getProjectId(), action, approvalPayloadHash(action, frozenParams), name => { projectName = name; sandboxDisplayRead = true; }, frozenParams);
|
|
396
397
|
if (isTesterRequest(action))
|
|
397
398
|
await logTesterRequestAudit('tester_request', action, data.id, null);
|
|
398
399
|
fireWebhookEvent('approval.requested', {
|
|
@@ -414,16 +415,18 @@ export async function requestApproval(action, params, apiKeyId, channelContext,
|
|
|
414
415
|
catch { /* Missing display data omits the project line. */ }
|
|
415
416
|
const approveUrl = approvalPageUrl(data.id);
|
|
416
417
|
const previewText = buildApprovalPreview({ action, params: frozenParams, summary: detailSummary, projectName, ...(display && 'recipient_count' in display ? { recipientCount: display.recipient_count } : {}) }).plain_summary;
|
|
417
|
-
if (
|
|
418
|
+
if (sandboxAttempt.approved) {
|
|
418
419
|
return { requires_approval: true, approval_id: data.id, tier, action, summary, expires_at: expiresAt,
|
|
419
420
|
approve_via: 'sandbox', decided_via: 'sandbox', decided_by: null, approve_url: approveUrl, preview_text: previewText, decision_status: 'approved',
|
|
420
421
|
message: approvalNext(action, { approve_via: 'sandbox', decision_status: 'approved' }) };
|
|
421
422
|
}
|
|
423
|
+
const autoApproveUnavailableReason = sandboxAttempt.reason;
|
|
422
424
|
const chatGuidance = tier === 'gated' && await humanApprovalEligible(action, getProjectId(), actor, true)
|
|
423
425
|
? `\nAsk the user in plain chat: "${previewText.split('\n')[0].trim()}". Only after they say yes, call confirm_approval with approval_id, action, echo (the first line of preview_text) and user_reply (their exact words), then ${approvalRetryInstruction(action)}`
|
|
424
426
|
: '';
|
|
425
427
|
const result = await elicitApproval({
|
|
426
428
|
requires_approval: true,
|
|
429
|
+
auto_approve_unavailable_reason: autoApproveUnavailableReason,
|
|
427
430
|
...(effective.channelRule === 'human_only' ? {} : await offerApprovalApp({ id: data.id, action, params: frozenParams, summary, tier, expires_at: expiresAt,
|
|
428
431
|
payload_hash: approvalPayloadHash(action, frozenParams) })),
|
|
429
432
|
approval_id: data.id,
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export const GATED_ACTIONS = new Set([
|
|
2
|
+
// #4392: edits and reversible archive/disable transitions affect public visitors.
|
|
3
|
+
'update_form',
|
|
4
|
+
'archive_form',
|
|
5
|
+
'update_short_link',
|
|
6
|
+
'update_asset_link',
|
|
2
7
|
'set_link_domain', // domain routing and optional customer DNS writes, one approval
|
|
3
8
|
'recheck_domain_dns', // live provider verification may request an EE certificate
|
|
4
9
|
'draft_agent_reply',
|
|
@@ -8,6 +8,18 @@ const projectRule = 'On hosted /mcp, pass project_id on each call. It is require
|
|
|
8
8
|
const previewRule = 'preview_email uses the real send renderer with sample contact data. send_test_email with dry_run:true uses the real renderer without sending or needing approval, including in standard mode. Stdio requires SENDINEL_API_KEY.';
|
|
9
9
|
const sender = { n: 1, tool: 'get_sites', args_example: {}, note: 'Choose the intended sender from the returned array; do not assume the first sender is correct. Replace example content and addresses with the user’s own choices.' };
|
|
10
10
|
export const HOW_TO_RECIPES = [
|
|
11
|
+
{
|
|
12
|
+
id: 'clean-up-test-objects', title: 'Clean up sandbox test contacts and objects',
|
|
13
|
+
when_to_use: 'Remove throwaway contacts or objects created by this credential after a sandbox test.',
|
|
14
|
+
synonyms: TOOL_ALIASES.delete_subscriber_data,
|
|
15
|
+
requires: { scopes: ['admin'], tester_ok: false },
|
|
16
|
+
steps: [
|
|
17
|
+
{ n: 1, tool: 'delete_subscriber_data', args_example: { contact_id: '11111111-1111-4111-8111-111111111111', reason: 'admin' }, approval, note: 'Replace contact_id with the saved ID of a test contact you created. This is irreversible GDPR erasure, including related data. In a sandbox project, same-credential provenance permits automatic approval; inspect decision_status and auto_approve_unavailable_reason. If pending, show approve_url and wait for human approval.' },
|
|
18
|
+
{ n: 2, tool: 'delete_subscriber_data', args_example: { approval_id: from(1, 'approval_id') }, approval, note: 'Only after decision_status is approved, re-call with ONLY approval_id to execute. Repeat for each intended test contact.' },
|
|
19
|
+
],
|
|
20
|
+
gotchas: [projectRule, 'is_test:true is only a contact classification, never delete authority. Non-sandbox projects keep human approval. Unknown or foreign provenance stays gated. Campaign cleanup uses delete_campaign with campaign_id; every cascading step must also belong to this credential. Templates and segments use delete_template with id and delete_segment with segment_id, with the same approval_id flow.'],
|
|
21
|
+
done_when: 'Each intended cleanup execution succeeds; automatic approval alone has not deleted anything.',
|
|
22
|
+
},
|
|
11
23
|
{
|
|
12
24
|
id: 'brand-kit-signature', title: 'Preview and save a brand-kit email signature',
|
|
13
25
|
when_to_use: 'Set up or edit a signature footer for future AI-generated drafts using stored brand details.',
|
|
@@ -25,4 +25,7 @@ export declare function withCreatedItems<T extends {
|
|
|
25
25
|
error: any;
|
|
26
26
|
}>(entityType: CreatedItemType, query: PromiseLike<T>): Promise<T>;
|
|
27
27
|
/** Unknown actions/targets and partial reads fail closed, including defensive bulk inputs. */
|
|
28
|
+
export type DeleteOwnershipUnavailableReason = 'not_created_by_this_credential' | 'unknown_provenance';
|
|
29
|
+
export declare function deleteOwnershipUnavailableReason(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<DeleteOwnershipUnavailableReason | null>;
|
|
30
|
+
/** Preserve the boolean authority API; diagnostics never supply another approval path. */
|
|
28
31
|
export declare function ownsDeleteTargets(projectId: string, action: string, params: Record<string, unknown>, creatorKey: string | null): Promise<boolean>;
|
|
@@ -54,11 +54,10 @@ export async function withCreatedItems(entityType, query) {
|
|
|
54
54
|
return result;
|
|
55
55
|
}
|
|
56
56
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
57
|
-
|
|
58
|
-
export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
57
|
+
export async function deleteOwnershipUnavailableReason(projectId, action, params, creatorKey) {
|
|
59
58
|
const target = DELETE_ITEM_TARGETS[action];
|
|
60
59
|
if (!creatorKey || !target)
|
|
61
|
-
return
|
|
60
|
+
return 'unknown_provenance';
|
|
62
61
|
const [entityType, single, bulk] = target;
|
|
63
62
|
const source = adminDb().schema('email');
|
|
64
63
|
let ids = [];
|
|
@@ -66,36 +65,46 @@ export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
|
66
65
|
ids.push(params[single]);
|
|
67
66
|
if (params[bulk] !== undefined) {
|
|
68
67
|
if (!Array.isArray(params[bulk]) || !params[bulk].length)
|
|
69
|
-
return
|
|
68
|
+
return 'unknown_provenance';
|
|
70
69
|
ids.push(...params[bulk]);
|
|
71
70
|
}
|
|
72
71
|
// GDPR snapshots carry both ID and email. Verify the address names that same target.
|
|
73
72
|
if (action === 'delete_subscriber_data' && params.email !== undefined) {
|
|
74
73
|
if (typeof params.email !== 'string' || !params.email.trim())
|
|
75
|
-
return
|
|
74
|
+
return 'unknown_provenance';
|
|
76
75
|
const { data, error } = await source.from('subscribers').select('id').eq('project_id', projectId)
|
|
77
76
|
.eq('email', params.email.trim().toLowerCase()).maybeSingle();
|
|
78
77
|
if (error || !data || (ids.length && (ids.length !== 1 || ids[0] !== data.id)))
|
|
79
|
-
return
|
|
78
|
+
return 'unknown_provenance';
|
|
80
79
|
ids.push(data.id);
|
|
81
80
|
}
|
|
82
81
|
if (!ids.length || ids.some(id => typeof id !== 'string' || !uuid.test(id)))
|
|
83
|
-
return
|
|
82
|
+
return 'unknown_provenance';
|
|
84
83
|
ids = [...new Set(ids)];
|
|
85
84
|
const owns = async (type, targets) => {
|
|
86
|
-
const { data, error } = await source.from('mcp_created_items').select('entity_id')
|
|
87
|
-
.eq('project_id', projectId).eq('entity_type', type).
|
|
88
|
-
|
|
85
|
+
const { data, error } = await source.from('mcp_created_items').select('entity_id, creator_key')
|
|
86
|
+
.eq('project_id', projectId).eq('entity_type', type).in('entity_id', targets);
|
|
87
|
+
if (error || !Array.isArray(data))
|
|
88
|
+
return 'unknown_provenance';
|
|
89
|
+
if (targets.some(id => !data.some(row => row.entity_id === id && typeof row.creator_key === 'string')))
|
|
90
|
+
return 'unknown_provenance';
|
|
91
|
+
return targets.every(id => data.some(row => row.entity_id === id && row.creator_key === creatorKey))
|
|
92
|
+
? null : 'not_created_by_this_credential';
|
|
89
93
|
};
|
|
90
|
-
|
|
91
|
-
|
|
94
|
+
const reason = await owns(entityType, ids);
|
|
95
|
+
if (reason)
|
|
96
|
+
return reason;
|
|
92
97
|
// Deleting a campaign also deletes its steps; another client may have added one.
|
|
93
98
|
if (action === 'delete_campaign') {
|
|
94
99
|
const { data, error } = await source.from('campaign_steps').select('id').in('campaign_id', ids);
|
|
95
100
|
if (error || !Array.isArray(data))
|
|
96
|
-
return
|
|
97
|
-
if (data.length
|
|
98
|
-
return
|
|
101
|
+
return 'unknown_provenance';
|
|
102
|
+
if (data.length)
|
|
103
|
+
return await owns('campaign_steps', data.map(row => row.id));
|
|
99
104
|
}
|
|
100
|
-
return
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** Preserve the boolean authority API; diagnostics never supply another approval path. */
|
|
108
|
+
export async function ownsDeleteTargets(projectId, action, params, creatorKey) {
|
|
109
|
+
return await deleteOwnershipUnavailableReason(projectId, action, params, creatorKey) === null;
|
|
101
110
|
}
|
|
@@ -26,6 +26,14 @@ export declare const SANDBOX_ADMIN_SCOPE: {
|
|
|
26
26
|
readonly set_team_member_role: 'org';
|
|
27
27
|
readonly tenant_export_full: 'project';
|
|
28
28
|
};
|
|
29
|
+
export type AutoApproveUnavailableReason = 'not_sandbox' | 'not_created_by_this_credential' | 'unknown_provenance' | 'action_not_eligible' | 'org_not_all_sandbox' | 'approval_policy' | 'auto_approval_failed';
|
|
29
30
|
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
31
|
+
export declare function sandboxAutoApproveUnavailableReason(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<AutoApproveUnavailableReason | null>;
|
|
32
|
+
/** Existing callers keep their boolean contract and the same fail-closed conditions. */
|
|
30
33
|
export declare function sandboxApprovalEligible(projectId: string, action: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
|
|
34
|
+
/** Capture the refusal from the same attempt, without a second mutable authority read. */
|
|
35
|
+
export declare function trySandboxApprovalWithReason(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<{
|
|
36
|
+
approved: boolean;
|
|
37
|
+
reason?: AutoApproveUnavailableReason;
|
|
38
|
+
}>;
|
|
31
39
|
export declare function trySandboxApproval(id: string, projectId: string, action: string, payloadHash: string, onProjectRead?: (name: string | undefined) => void, params?: Record<string, unknown>): Promise<boolean>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { adminDb } from '../db.js';
|
|
2
|
-
import { DELETE_ITEM_TARGETS,
|
|
2
|
+
import { DELETE_ITEM_TARGETS, deleteOwnershipUnavailableReason, requestingCreatorKey } from './mcp-created-items.js';
|
|
3
3
|
import { approvalClass } from '@sendinel/domain';
|
|
4
4
|
import { PROTECTED_ACTIONS } from './approval-actions.js';
|
|
5
5
|
import { isHumanRequestableAdminTool } from './admin-tools.js';
|
|
@@ -16,48 +16,63 @@ export const SANDBOX_ADMIN_SCOPE = {
|
|
|
16
16
|
tenant_export_full: 'project',
|
|
17
17
|
};
|
|
18
18
|
/** Never consult token claims, BYOD clients or cached display data for this authority. */
|
|
19
|
-
export async function
|
|
19
|
+
export async function sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params = {}) {
|
|
20
20
|
const deletion = approvalClass(action) === 'D' || action.startsWith('delete_');
|
|
21
21
|
// No exception for shared org credentials, unknown delete actions or other protected effects.
|
|
22
|
-
if (deletion &&
|
|
23
|
-
return
|
|
22
|
+
if (deletion && !(action in DELETE_ITEM_TARGETS))
|
|
23
|
+
return 'action_not_eligible';
|
|
24
|
+
if (deletion && !requestingCreatorKey())
|
|
25
|
+
return 'unknown_provenance';
|
|
24
26
|
if (action === 'delete_provider_credential' || (!deletion && PROTECTED_ACTIONS.has(action)))
|
|
25
|
-
return
|
|
27
|
+
return 'action_not_eligible';
|
|
26
28
|
try {
|
|
27
29
|
const source = adminDb().schema('email');
|
|
28
30
|
const { data, error } = await source.from('projects').select('id, org_id, sandbox_mode, name')
|
|
29
31
|
.eq('id', projectId).maybeSingle();
|
|
30
32
|
onProjectRead?.(!error && typeof data?.name === 'string' ? data.name : undefined);
|
|
31
|
-
if (error || data?.id !== projectId
|
|
32
|
-
return
|
|
33
|
+
if (error || data?.id !== projectId)
|
|
34
|
+
return 'unknown_provenance';
|
|
35
|
+
if (data.sandbox_mode !== true)
|
|
36
|
+
return 'not_sandbox';
|
|
33
37
|
if (deletion)
|
|
34
|
-
return await
|
|
38
|
+
return await deleteOwnershipUnavailableReason(projectId, action, params, requestingCreatorKey());
|
|
35
39
|
if (!isHumanRequestableAdminTool(action))
|
|
36
|
-
return
|
|
40
|
+
return null;
|
|
37
41
|
const scope = SANDBOX_ADMIN_SCOPE[action];
|
|
38
42
|
if (scope === 'project')
|
|
39
|
-
return
|
|
43
|
+
return null;
|
|
40
44
|
if (scope !== 'org' || typeof data.org_id !== 'string')
|
|
41
|
-
return
|
|
45
|
+
return 'action_not_eligible';
|
|
42
46
|
const others = await source.from('projects').select('id').eq('org_id', data.org_id)
|
|
43
47
|
.eq('sandbox_mode', false).limit(1);
|
|
44
|
-
|
|
48
|
+
if (others.error || !Array.isArray(others.data))
|
|
49
|
+
return 'unknown_provenance';
|
|
50
|
+
return others.data.length === 0 ? null : 'org_not_all_sandbox';
|
|
45
51
|
}
|
|
46
52
|
catch {
|
|
47
|
-
return
|
|
53
|
+
return 'unknown_provenance';
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
/** Existing callers keep their boolean contract and the same fail-closed conditions. */
|
|
57
|
+
export async function sandboxApprovalEligible(projectId, action, onProjectRead, params = {}) {
|
|
58
|
+
return await sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params) === null;
|
|
59
|
+
}
|
|
60
|
+
/** Capture the refusal from the same attempt, without a second mutable authority read. */
|
|
61
|
+
export async function trySandboxApprovalWithReason(id, projectId, action, payloadHash, onProjectRead, params = {}) {
|
|
62
|
+
const reason = await sandboxAutoApproveUnavailableReason(projectId, action, onProjectRead, params);
|
|
63
|
+
if (reason)
|
|
64
|
+
return { approved: false, reason };
|
|
53
65
|
try {
|
|
54
66
|
// The RPC performs one conditional UPDATE + audit INSERT in one transaction.
|
|
55
67
|
const { data, error } = await adminDb().schema('email').rpc('approve_sandbox_action', {
|
|
56
68
|
p_approval_id: id, p_project_id: projectId, p_action: action, p_payload_hash: payloadHash,
|
|
57
69
|
});
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
catch {
|
|
61
|
-
return false;
|
|
70
|
+
if (!error && data === true)
|
|
71
|
+
return { approved: true };
|
|
62
72
|
}
|
|
73
|
+
catch { /* An RPC failure never grants authority. */ }
|
|
74
|
+
return { approved: false, reason: 'auto_approval_failed' };
|
|
75
|
+
}
|
|
76
|
+
export async function trySandboxApproval(id, projectId, action, payloadHash, onProjectRead, params = {}) {
|
|
77
|
+
return (await trySandboxApprovalWithReason(id, projectId, action, payloadHash, onProjectRead, params)).approved;
|
|
63
78
|
}
|
|
@@ -14,6 +14,7 @@ export async function resolveSurveyShortLink(sb, projectId, surveyId) {
|
|
|
14
14
|
.from('short_links')
|
|
15
15
|
.select('id, slug, project_id')
|
|
16
16
|
.eq('id', survey.short_link_id)
|
|
17
|
+
.is('disabled_at', null)
|
|
17
18
|
.eq('project_id', projectId)
|
|
18
19
|
.maybeSingle();
|
|
19
20
|
return link?.slug ? `${appBaseUrl()}/s/${link.slug}` : null;
|
package/build/server.js
CHANGED
|
@@ -120,7 +120,7 @@ const WRITE_TOOLS = new Set([
|
|
|
120
120
|
'trigger_automation', 'enroll_automation', 'create_escalation_ladder', 'enroll_in_escalation_ladder',
|
|
121
121
|
'update_scoring_rules', 'register_domain', 'publish_dmarc_record',
|
|
122
122
|
'update_warmup_schedule', 'pause_warmup', 'resume_warmup',
|
|
123
|
-
'create_form', 'invite_team_member',
|
|
123
|
+
'create_form', 'update_form', 'archive_form', 'invite_team_member',
|
|
124
124
|
'remove_team_member', 'set_team_member_role', 'set_member_project_access',
|
|
125
125
|
'set_active_project',
|
|
126
126
|
'resend_invitation', 'revoke_invitation',
|
|
@@ -129,7 +129,7 @@ const WRITE_TOOLS = new Set([
|
|
|
129
129
|
'create_api_key', 'revoke_api_key', 'mark_notifications_read',
|
|
130
130
|
'migrate_campaigns_from', 'start_platform_migration',
|
|
131
131
|
'create_content_block', 'delete_content_block', 'trigger_content_pipeline',
|
|
132
|
-
'create_asset_link',
|
|
132
|
+
'create_asset_link', 'update_asset_link',
|
|
133
133
|
'set_white_label_settings', 'add_white_label_domain',
|
|
134
134
|
'create_blog_post',
|
|
135
135
|
'generate_blog_post_image',
|
|
@@ -141,7 +141,7 @@ const WRITE_TOOLS = new Set([
|
|
|
141
141
|
'create_custom_webhook', 'rotate_custom_webhook_secret',
|
|
142
142
|
'update_brand_voice', 'promote_draft_to_campaign',
|
|
143
143
|
'propose_campaign_from_data', 'approve_data_proposal',
|
|
144
|
-
'create_survey', 'update_survey', 'create_short_link', 'generate_qr_code',
|
|
144
|
+
'create_survey', 'update_survey', 'create_short_link', 'update_short_link', 'generate_qr_code',
|
|
145
145
|
'create_signature', 'update_signature',
|
|
146
146
|
'mark_ai_work_item_seen', 'snooze_ai_work_item', 'dismiss_ai_work_item',
|
|
147
147
|
'submit_ai_context_event',
|
package/build/tools/contacts.js
CHANGED
|
@@ -1315,6 +1315,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1315
1315
|
company_name: z.string().optional(),
|
|
1316
1316
|
company_domain: z.string().optional(),
|
|
1317
1317
|
tags: z.array(z.string()).optional().describe('Initial tags for new subscribers; existing contacts keep their stored tags.'),
|
|
1318
|
+
is_test: z.boolean().optional().describe('Mark a NEW contact as a test contact: excluded from headline counts and default audiences. Ignored for existing contacts.'),
|
|
1318
1319
|
source: z.enum(['signup', 'import', 'form', 'manual']).optional().default('manual'),
|
|
1319
1320
|
utm_source: z.string().optional(),
|
|
1320
1321
|
utm_medium: z.string().optional(),
|
|
@@ -1322,7 +1323,7 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1322
1323
|
utm_content: z.string().optional(),
|
|
1323
1324
|
utm_term: z.string().optional(),
|
|
1324
1325
|
},
|
|
1325
|
-
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1326
|
+
}, async ({ email, site_id, first_name, last_name, company_id, company_name, company_domain, tags, is_test, source, utm_source, utm_medium, utm_campaign, utm_content, utm_term }) => {
|
|
1326
1327
|
try {
|
|
1327
1328
|
const normalizedTags = normalizeTagInput(tags ?? []);
|
|
1328
1329
|
const warnings = tagInputWarnings(normalizedTags);
|
|
@@ -1370,6 +1371,8 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1370
1371
|
let contact;
|
|
1371
1372
|
if (!existingContact) {
|
|
1372
1373
|
const row = { project_id: projectId, email, first_name, last_name, company_id: resolvedCompanyId, source, source_site: site_id };
|
|
1374
|
+
if (is_test === true)
|
|
1375
|
+
row.is_test = true;
|
|
1373
1376
|
if (tags !== undefined)
|
|
1374
1377
|
row.tags = normalizedTags.tags;
|
|
1375
1378
|
if (customProperties)
|
|
@@ -1447,11 +1450,11 @@ Requirements: urgency without desperation, acknowledge the gap, one clear CTA, v
|
|
|
1447
1450
|
if (needsConfirm) {
|
|
1448
1451
|
// Create opt-in token (confirmation email must be sent externally in MCP context)
|
|
1449
1452
|
await db().from('opt_in_tokens').insert({ contact_id: contact.id, site_id });
|
|
1450
|
-
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1453
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}), ...(!existing && is_test === true ? { is_test: contact.is_test } : {}) });
|
|
1451
1454
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_pending_double_opt_in' });
|
|
1452
1455
|
return { content: [{ type: 'text', text: JSON.stringify({ ...contact, ...tagWarnings, existing, confirmation: 'pending', message: 'Double opt-in required. Confirmation token created.', ...existingMessage }, null, 2) }] };
|
|
1453
1456
|
}
|
|
1454
|
-
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}) });
|
|
1457
|
+
await writeBackMcpContact(projectId, contact, { email, first_name: contact.first_name ?? null, last_name: contact.last_name ?? null, ...(!existing && tags !== undefined ? { tags: normalizedTags.tags } : {}), ...(!existing && is_test === true ? { is_test: contact.is_test } : {}) });
|
|
1455
1458
|
logConsent({ contactId: contact.id, siteId: site_id, action: 'grant', consentType: 'email_marketing', source: 'mcp_add_subscriber' });
|
|
1456
1459
|
if (!existing)
|
|
1457
1460
|
logAudit({ action: 'contact.created', resourceType: 'contact', resourceId: contact.id, details: { email, source } });
|
package/build/tools/content.js
CHANGED
|
@@ -143,6 +143,48 @@ export function registerContentTools(server) {
|
|
|
143
143
|
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai';
|
|
144
144
|
return j({ asset_link: { ...data, url: `${appUrl.replace(/\/$/, '')}/a/${data.slug}` } });
|
|
145
145
|
});
|
|
146
|
+
server.registerTool('update_asset_link', {
|
|
147
|
+
title: 'Update Asset Link',
|
|
148
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
149
|
+
description: 'Update a project asset link or reversibly archive/restore it. Keeps its slug and view history. Requires approval in standard mode.',
|
|
150
|
+
inputSchema: {
|
|
151
|
+
asset_link_id: z.string().uuid(),
|
|
152
|
+
title: z.string().min(1).max(255).optional(),
|
|
153
|
+
asset_url: z.string().url().max(2000).optional(),
|
|
154
|
+
gate_headline: z.string().max(255).optional(),
|
|
155
|
+
gate_body: z.string().max(1000).optional(),
|
|
156
|
+
source_attribution: z.string().max(500).optional(),
|
|
157
|
+
status: z.enum(['draft', 'active', 'archived']).optional(),
|
|
158
|
+
approval_id: z.string().uuid().optional(),
|
|
159
|
+
},
|
|
160
|
+
}, withApproval('update_asset_link', async ({ asset_link_id, title, asset_url, gate_headline, gate_body, source_attribution, status }) => {
|
|
161
|
+
for (const [field, value] of Object.entries({ title, source_attribution, gate_headline, gate_body })) {
|
|
162
|
+
const error = refuseActiveTextFields(field, value);
|
|
163
|
+
if (error)
|
|
164
|
+
return j({ error, code: 'validation_failed' });
|
|
165
|
+
}
|
|
166
|
+
if (asset_url !== undefined) {
|
|
167
|
+
try {
|
|
168
|
+
assertHttpUrl(asset_url);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return j({ error: 'asset_url must use http or https', code: 'validation_failed' });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const patch = Object.fromEntries(Object.entries({ title, asset_url, gate_headline, gate_body, source_attribution, status })
|
|
175
|
+
.filter(([, value]) => value !== undefined));
|
|
176
|
+
if (!Object.keys(patch).length)
|
|
177
|
+
return j({ error: 'Provide at least one asset link field to update', code: 'validation_failed' });
|
|
178
|
+
const { data, error } = await db().from('asset_links').update({ ...patch, updated_at: new Date().toISOString() })
|
|
179
|
+
.eq('id', asset_link_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
180
|
+
if (error)
|
|
181
|
+
return j(dbToolError(error));
|
|
182
|
+
if (!data)
|
|
183
|
+
return j({ error: 'Asset link not found', code: 'NOT_FOUND' });
|
|
184
|
+
await logAudit({ action: 'asset_link.updated', resourceType: 'asset_link', resourceId: asset_link_id, details: patch });
|
|
185
|
+
const appUrl = (process.env.NEXT_PUBLIC_APP_URL ?? process.env.SENDINEL_APP_URL ?? 'https://sendinel.ai').replace(/\/$/, '');
|
|
186
|
+
return j({ asset_link: { ...data, url: `${appUrl}/a/${data.slug}` } });
|
|
187
|
+
}));
|
|
146
188
|
server.registerTool('list_asset_links', {
|
|
147
189
|
title: 'List Asset Links',
|
|
148
190
|
annotations: {
|
package/build/tools/forms.js
CHANGED
|
@@ -2,8 +2,29 @@ import { z } from 'zod';
|
|
|
2
2
|
import { db } from '../db.js';
|
|
3
3
|
import { getProjectId } from '../project.js';
|
|
4
4
|
import { assertHttpUrl } from '../lib/url-validation.js';
|
|
5
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
6
|
+
import { logAudit } from '../audit.js';
|
|
5
7
|
import { dbToolError } from '../lib/db-error.js';
|
|
6
8
|
const j = (data) => ({ content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] });
|
|
9
|
+
function validateFields(fields) {
|
|
10
|
+
if (!fields.length)
|
|
11
|
+
return 'fields must contain at least one field';
|
|
12
|
+
const names = new Set();
|
|
13
|
+
for (const field of fields) {
|
|
14
|
+
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
15
|
+
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
16
|
+
return 'Each field needs a name (lowercase snake_case) and a supported type';
|
|
17
|
+
}
|
|
18
|
+
if (names.has(field.name))
|
|
19
|
+
return `Duplicate field name: ${field.name}`;
|
|
20
|
+
names.add(field.name);
|
|
21
|
+
}
|
|
22
|
+
// The public handler always reads fields.email to identify the subscriber.
|
|
23
|
+
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
24
|
+
return 'fields must include an email field named email';
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
7
28
|
export function registerFormTools(server) {
|
|
8
29
|
server.registerTool('list_forms', {
|
|
9
30
|
title: 'List Forms',
|
|
@@ -70,22 +91,9 @@ export function registerFormTools(server) {
|
|
|
70
91
|
form_type: z.enum(['signup', 'management']).optional(),
|
|
71
92
|
},
|
|
72
93
|
}, async ({ site_id, name, fields, redirect_url, double_opt_in, form_type }) => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
for (const field of fields) {
|
|
77
|
-
if (typeof field.name !== 'string' || !/^[a-z][a-z0-9_]{0,49}$/.test(field.name) ||
|
|
78
|
-
typeof field.type !== 'string' || !['email', 'text', 'textarea', 'select', 'checkbox', 'number', 'tel', 'url', 'date'].includes(field.type)) {
|
|
79
|
-
return j({ error: 'Each field needs a name (lowercase snake_case) and a supported type', code: 'validation_failed' });
|
|
80
|
-
}
|
|
81
|
-
if (names.has(field.name))
|
|
82
|
-
return j({ error: `Duplicate field name: ${field.name}`, code: 'validation_failed' });
|
|
83
|
-
names.add(field.name);
|
|
84
|
-
}
|
|
85
|
-
// The public handler always reads fields.email to identify the subscriber.
|
|
86
|
-
if (!fields.some(field => field.name === 'email' && field.type === 'email')) {
|
|
87
|
-
return j({ error: 'fields must include an email field named email', code: 'validation_failed' });
|
|
88
|
-
}
|
|
94
|
+
const fieldsError = validateFields(fields);
|
|
95
|
+
if (fieldsError)
|
|
96
|
+
return j({ error: fieldsError, code: 'validation_failed' });
|
|
89
97
|
if (redirect_url !== undefined) {
|
|
90
98
|
try {
|
|
91
99
|
assertHttpUrl(redirect_url);
|
|
@@ -105,4 +113,87 @@ export function registerFormTools(server) {
|
|
|
105
113
|
return j({ ...dbToolError(error), code: 'INTERNAL_ERROR' });
|
|
106
114
|
return j({ form: data });
|
|
107
115
|
});
|
|
116
|
+
server.registerTool('update_form', {
|
|
117
|
+
title: 'Update Form',
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
119
|
+
description: 'Update a project form without changing its slug or submissions. Settings edits preserve other settings. Requires approval in standard mode.',
|
|
120
|
+
inputSchema: {
|
|
121
|
+
form_id: z.string().uuid(),
|
|
122
|
+
name: z.string().min(1).optional(),
|
|
123
|
+
fields: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
124
|
+
redirect_url: z.string().url().optional(),
|
|
125
|
+
double_opt_in: z.boolean().optional(),
|
|
126
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
127
|
+
approval_id: z.string().uuid().optional(),
|
|
128
|
+
},
|
|
129
|
+
}, withApproval('update_form', async ({ form_id, name, fields, redirect_url, double_opt_in, status }) => {
|
|
130
|
+
if (fields !== undefined) {
|
|
131
|
+
const error = validateFields(fields);
|
|
132
|
+
if (error)
|
|
133
|
+
return j({ error, code: 'validation_failed' });
|
|
134
|
+
}
|
|
135
|
+
if (redirect_url !== undefined) {
|
|
136
|
+
try {
|
|
137
|
+
assertHttpUrl(redirect_url);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return j({ error: 'redirect_url must use http or https', code: 'validation_failed' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const projectId = getProjectId();
|
|
144
|
+
const { data: form, error: readError } = await db().from('signup_forms').select('id, settings')
|
|
145
|
+
.eq('id', form_id).eq('project_id', projectId).maybeSingle();
|
|
146
|
+
if (readError)
|
|
147
|
+
return j(dbToolError(readError));
|
|
148
|
+
if (!form)
|
|
149
|
+
return j({ error: 'Form not found', code: 'NOT_FOUND' });
|
|
150
|
+
const patch = {};
|
|
151
|
+
if (name !== undefined)
|
|
152
|
+
patch.name = name;
|
|
153
|
+
if (fields !== undefined)
|
|
154
|
+
patch.fields = fields;
|
|
155
|
+
if (status !== undefined)
|
|
156
|
+
patch.status = status;
|
|
157
|
+
const settingsChanged = redirect_url !== undefined || double_opt_in !== undefined;
|
|
158
|
+
if (settingsChanged) {
|
|
159
|
+
patch.settings = { ...form.settings,
|
|
160
|
+
...(redirect_url !== undefined ? { redirect_url } : {}),
|
|
161
|
+
...(double_opt_in !== undefined ? { double_opt_in } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (!Object.keys(patch).length)
|
|
165
|
+
return j({ error: 'Provide at least one form field to update', code: 'validation_failed' });
|
|
166
|
+
patch.updated_at = new Date().toISOString();
|
|
167
|
+
let query = db().from('signup_forms').update(patch).eq('id', form_id).eq('project_id', projectId);
|
|
168
|
+
// Do not overwrite a concurrent settings edit with the earlier JSON snapshot.
|
|
169
|
+
if (settingsChanged)
|
|
170
|
+
query = query.eq('settings', JSON.stringify(form.settings));
|
|
171
|
+
const { data, error } = await query.select().maybeSingle();
|
|
172
|
+
if (error)
|
|
173
|
+
return j(dbToolError(error));
|
|
174
|
+
if (!data)
|
|
175
|
+
return j({ error: 'Form changed; read it again before retrying', code: 'conflict' });
|
|
176
|
+
await logAudit({ action: 'form.updated', resourceType: 'form', resourceId: form_id, details: patch });
|
|
177
|
+
return j({ form: data });
|
|
178
|
+
}));
|
|
179
|
+
server.registerTool('archive_form', {
|
|
180
|
+
title: 'Archive Form',
|
|
181
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
182
|
+
description: 'Reversibly archive a project form, or restore it with archived=false. Keeps submissions. Requires approval in standard mode.',
|
|
183
|
+
inputSchema: {
|
|
184
|
+
form_id: z.string().uuid(),
|
|
185
|
+
archived: z.boolean().optional().default(true),
|
|
186
|
+
approval_id: z.string().uuid().optional(),
|
|
187
|
+
},
|
|
188
|
+
}, withApproval('archive_form', async ({ form_id, archived = true }) => {
|
|
189
|
+
const status = archived ? 'archived' : 'active';
|
|
190
|
+
const { data, error } = await db().from('signup_forms').update({ status, updated_at: new Date().toISOString() })
|
|
191
|
+
.eq('id', form_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
192
|
+
if (error)
|
|
193
|
+
return j(dbToolError(error));
|
|
194
|
+
if (!data)
|
|
195
|
+
return j({ error: 'Form not found', code: 'NOT_FOUND' });
|
|
196
|
+
await logAudit({ action: 'form.updated', resourceType: 'form', resourceId: form_id, details: { status } });
|
|
197
|
+
return j({ form: data });
|
|
198
|
+
}));
|
|
108
199
|
}
|
package/build/tools/gdpr.js
CHANGED
|
@@ -87,7 +87,7 @@ export function registerGdprTools(server) {
|
|
|
87
87
|
idempotentHint: false,
|
|
88
88
|
openWorldHint: false,
|
|
89
89
|
},
|
|
90
|
-
description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE.
|
|
90
|
+
description: 'GDPR Article 17: Permanently delete a subscriber and all their data. Anonymizes email_log, hashes suppressions, cascades to subscriptions/enrollments/tokens. This action is IRREVERSIBLE. Also use for test contact cleanup (reason: admin). In a sandbox project, contacts created by the same credential are erased without a human click via the approval_id retry flow. Other cases require human approval via dashboard.',
|
|
91
91
|
inputSchema: {
|
|
92
92
|
contact_id: z.string().uuid().describe('UUID of the subscriber to delete'),
|
|
93
93
|
reason: z.enum(['gdpr_request', 'user_request', 'admin']).optional().default('gdpr_request'),
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod';
|
|
|
3
3
|
import { db } from '../db.js';
|
|
4
4
|
import { getProjectId } from '../project.js';
|
|
5
5
|
import { assertHttpUrl, RESERVED_LINK_SLUGS } from '../lib/url-validation.js';
|
|
6
|
+
import { withApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { logAudit } from '../audit.js';
|
|
6
8
|
import { dbToolError } from '../lib/db-error.js';
|
|
7
9
|
const txt = (text) => ({ content: [{ type: 'text', text }] });
|
|
8
10
|
const j = (data) => txt(JSON.stringify(data, null, 2));
|
|
@@ -85,6 +87,42 @@ export function registerShortLinkTools(server) {
|
|
|
85
87
|
return j({ ...dbToolError(error), code: 'INTERNAL_ERROR' });
|
|
86
88
|
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
87
89
|
});
|
|
90
|
+
server.registerTool('update_short_link', {
|
|
91
|
+
title: 'Update Short Link',
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
93
|
+
description: 'Update a project short link or reversibly disable/re-enable it. Keeps click history. Requires approval in standard mode.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
short_link_id: z.string().uuid(),
|
|
96
|
+
destination_url: z.string().url().optional(),
|
|
97
|
+
disabled: z.boolean().optional(),
|
|
98
|
+
reason: z.string().optional(),
|
|
99
|
+
approval_id: z.string().uuid().optional(),
|
|
100
|
+
},
|
|
101
|
+
}, withApproval('update_short_link', async ({ short_link_id, destination_url, disabled, reason }) => {
|
|
102
|
+
const patch = {};
|
|
103
|
+
if (destination_url !== undefined) {
|
|
104
|
+
try {
|
|
105
|
+
patch.destination_url = assertHttpUrl(destination_url);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return j({ error: 'destination_url must use http or https', code: 'validation_failed' });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (disabled !== undefined) {
|
|
112
|
+
patch.disabled_at = disabled ? new Date().toISOString() : null;
|
|
113
|
+
patch.disabled_reason = disabled ? reason ?? null : null;
|
|
114
|
+
}
|
|
115
|
+
if (!Object.keys(patch).length)
|
|
116
|
+
return j({ error: 'Provide destination_url or disabled', code: 'validation_failed' });
|
|
117
|
+
const { data, error } = await db().from('short_links').update(patch)
|
|
118
|
+
.eq('id', short_link_id).eq('project_id', getProjectId()).select().maybeSingle();
|
|
119
|
+
if (error)
|
|
120
|
+
return j(dbToolError(error));
|
|
121
|
+
if (!data)
|
|
122
|
+
return j({ error: 'Short link not found', code: 'NOT_FOUND' });
|
|
123
|
+
await logAudit({ action: 'short_link.updated', resourceType: 'short_link', resourceId: short_link_id, details: patch });
|
|
124
|
+
return j({ short_link: data, short_url: `${appBaseUrl()}/s/${data.slug}` });
|
|
125
|
+
}));
|
|
88
126
|
server.registerTool('get_short_link_stats', {
|
|
89
127
|
title: 'Get Short Link Stats',
|
|
90
128
|
annotations: {
|
|
@@ -99,7 +137,7 @@ export function registerShortLinkTools(server) {
|
|
|
99
137
|
const projectId = getProjectId();
|
|
100
138
|
const { data: link, error: linkError } = await db()
|
|
101
139
|
.from('short_links')
|
|
102
|
-
.select('id, slug, destination_url, click_count, last_clicked_at, created_at')
|
|
140
|
+
.select('id, slug, destination_url, click_count, last_clicked_at, created_at, disabled_at, disabled_reason')
|
|
103
141
|
.eq('id', short_link_id)
|
|
104
142
|
.eq('project_id', projectId)
|
|
105
143
|
.maybeSingle();
|
|
@@ -12,6 +12,7 @@ export const STOPWORDS = new Set([
|
|
|
12
12
|
* Only tools that exist in the registry belong here.
|
|
13
13
|
*/
|
|
14
14
|
export const TOOL_ALIASES = {
|
|
15
|
+
delete_subscriber_data: ['delete contact', 'delete contacts', 'remove subscriber', 'remove subscribers', 'clean up test contacts', 'clean up test objects', 'sandbox cleanup'],
|
|
15
16
|
list_custom_fields: ['custom fields', 'field definitions', 'contact schema', 'unused fields'],
|
|
16
17
|
update_site: ['footer', 'email footer', 'unsubscribe text', 'unsubscribe link', 'site footer', 'unsubscribe settings'],
|
|
17
18
|
set_campaign_trigger: ['trigger', 'event', 'fires on', 'fires-on', 'dunning', 'set campaign trigger event name', 'invoice payment failed', 'payment failed', 'trial ending', 'event trigger', 'billing'],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.216",
|
|
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",
|