@sendinel/mcp-server 1.0.158 → 1.0.160

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 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.
21
+ Sendinel currently exposes 35 tool groups and 348 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
  |-------|-------|---------|
@@ -49,7 +49,7 @@ Sendinel currently exposes 35 tool groups and 346 tools (gateway mode exposes 4
49
49
  | `segments` | 6 | Audience builder: create, edit, natural-language generation, preview |
50
50
  | `short-links` | 3 | Short links, click stats, QR codes |
51
51
  | `signatures` | 4 | Sending signature blocks |
52
- | `sites` | 5 | Site, sender, and brand voice management |
52
+ | `sites` | 7 | Site, sender, and brand voice management |
53
53
  | `skills` | 3 | Operator skills catalog |
54
54
  | `sms` | 7 | SMS send, log, consent, inbound |
55
55
  | `social-posts` | 17 | Social campaign creation, publisher scheduling, social posts, engagement reads, and LinkedIn reply proposals for human review |
@@ -1,3 +1,4 @@
1
+ import type { DecisionEvidence } from './approval-elicitation-evidence.js';
1
2
  export { isApprovalGatedAction } from './approval-actions.js';
2
3
  /**
3
4
  * Action approval tiers:
@@ -84,6 +85,13 @@ export declare function checkApproval(approvalId: string, options?: {
84
85
  consume?: boolean;
85
86
  action?: string;
86
87
  params?: Record<string, unknown>;
88
+ }): Promise<Awaited<ReturnType<typeof checkApprovalState>> & {
89
+ decision_evidence?: DecisionEvidence;
90
+ }>;
91
+ declare function checkApprovalState(approvalId: string, options?: {
92
+ consume?: boolean;
93
+ action?: string;
94
+ params?: Record<string, unknown>;
87
95
  }): Promise<{
88
96
  approved: boolean;
89
97
  status: string;
@@ -175,6 +175,7 @@ export function buildApprovalSummary(action, params, display = {}) {
175
175
  install_connector_view: (p) => `Reinstall the customer-side contact view for project ${p.project_id ?? 'current'}`,
176
176
  set_connector_mapping: () => `Rewrite the BYOD connector field mapping`,
177
177
  resync_connector: () => `Force a full BYOD connector resync`,
178
+ set_link_domain: (p) => `Set email link domain ${p.link_domain} for site ${p.site_id}${p.publish_dns ? ' and publish all DNS records through Cloudflare' : ''}`,
178
179
  publish_dmarc_record: (p) => `Publish DMARC record for ${p.domain} - writes DNS in a customer-owned Cloudflare zone`,
179
180
  broadcast_to_segment: (p) => broadcastSummary(display) ?? `Broadcast "${p.name || p.subject || 'untitled'}" to segment ${p.segment_id} — will send emails to real contacts`,
180
181
  send_broadcast: (p) => {
@@ -370,6 +371,31 @@ function captureSnapshot(action, params) {
370
371
  * Returns the approval if approved, null if still pending, throws if rejected/expired.
371
372
  */
372
373
  export async function checkApproval(approvalId, options = {}) {
374
+ const result = await checkApprovalState(approvalId, options);
375
+ if (options.consume || result.status === 'not_found')
376
+ return result;
377
+ try {
378
+ const source = approvalStore();
379
+ const client = typeof source.schema === 'function' ? source.schema('email') : source;
380
+ const { data: approval, error } = await client.from('action_approvals')
381
+ .select('decided_via, decided_by').eq('id', approvalId).eq('project_id', getProjectId()).maybeSingle();
382
+ if (error || approval?.decided_via !== 'mcp_elicitation' || !approval.decided_by)
383
+ return result;
384
+ const { data: rows, error: auditError } = await client.from('audit_log').select('details')
385
+ .eq('project_id', getProjectId()).eq('resource_type', 'approval').eq('resource_id', approvalId)
386
+ .eq('actor_id', approval.decided_by).in('action', ['approval.elicitation_answered', 'approval.submitted']).limit(10);
387
+ if (auditError)
388
+ return result;
389
+ const evidence = rows?.map((row) => row.details).find((details) => details?.channel === 'mcp_elicitation' && Number.isInteger(details.answer_latency_ms) &&
390
+ details.answer_latency_ms >= 0 && typeof details.auto_accept_suspected === 'boolean');
391
+ return evidence ? { ...result, decision_evidence: { answer_latency_ms: evidence.answer_latency_ms,
392
+ auto_accept_suspected: evidence.auto_accept_suspected, channel: 'mcp_elicitation' } } : result;
393
+ }
394
+ catch {
395
+ return result;
396
+ }
397
+ }
398
+ async function checkApprovalState(approvalId, options = {}) {
373
399
  const applied = registeredApproval.getStore();
374
400
  if (options.consume && applied?.phase === 'approved' && applied.id === approvalId && applied.action === options.action && applied.projectId === getProjectId()) {
375
401
  return { approved: true, status: 'approved', decided_by: applied.decidedBy, ...(applied.decisionChannel ? { decision_channel: applied.decisionChannel } : {}) };
@@ -1,4 +1,5 @@
1
1
  export const GATED_ACTIONS = new Set([
2
+ 'set_link_domain', // domain routing and optional customer DNS writes, one approval
2
3
  'recheck_domain_dns', // live provider verification may request an EE certificate
3
4
  'draft_agent_reply',
4
5
  'create_agent_mailbox',
@@ -19,9 +19,11 @@ export declare function declaredAppMimeTypes(declared: unknown): string[];
19
19
  */
20
20
  export declare function approvalAppEnabled(env?: NodeJS.ProcessEnv): boolean;
21
21
  export declare function approvalAppClientAllowed(clientName: unknown, env?: NodeJS.ProcessEnv): boolean;
22
- export declare function rememberApprovalCapabilities(session: object, declared: unknown, clientName?: unknown): ApprovalClientCapabilities;
22
+ export declare function rememberApprovalCapabilities(session: object, declared: unknown, clientName?: unknown, clientVersion?: unknown): ApprovalClientCapabilities;
23
23
  /** Name from this in-process session's initialize clientInfo; undefined when none was declared. */
24
24
  export declare function approvalClientName(session: object): string | undefined;
25
+ export declare function approvalClientVersion(session: object): string | undefined;
26
+ export declare function approvalDeclaredCapabilities(session: object): unknown;
25
27
  /**
26
28
  * Declared-capability probe for this in-process session. `appsAllowed: false` removes the Apps
27
29
  * tier from the choice (flag off / client not allowlisted), so a lower tier can still be used.
@@ -1,6 +1,7 @@
1
1
  export const MCP_APP_EXTENSION = 'io.modelcontextprotocol/ui';
2
2
  export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
3
3
  const sessions = new WeakMap();
4
+ const clientVersions = new WeakMap();
4
5
  const clientNames = new WeakMap();
5
6
  const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
6
7
  /** Reads only declared initialize capabilities. No user agent or client-name guessing. */
@@ -40,19 +41,33 @@ export function approvalAppClientAllowed(clientName, env = process.env) {
40
41
  const allowed = (env.MCP_APPROVAL_APP_CLIENTS ?? '').split(',').map(name => name.trim()).filter(Boolean);
41
42
  return allowed.includes(clientName.trim());
42
43
  }
43
- export function rememberApprovalCapabilities(session, declared, clientName) {
44
+ export function rememberApprovalCapabilities(session, declared, clientName, clientVersion) {
44
45
  const capabilities = probeApprovalCapabilities(declared);
45
46
  sessions.set(session, capabilities);
46
47
  if (typeof clientName === 'string')
47
48
  clientNames.set(session, clientName);
48
49
  else
49
50
  clientNames.delete(session);
51
+ if (typeof clientVersion === 'string')
52
+ clientVersions.set(session, clientVersion);
53
+ else
54
+ clientVersions.delete(session);
50
55
  return capabilities;
51
56
  }
52
57
  /** Name from this in-process session's initialize clientInfo; undefined when none was declared. */
53
58
  export function approvalClientName(session) {
54
59
  return clientNames.get(session);
55
60
  }
61
+ export function approvalClientVersion(session) {
62
+ return clientVersions.get(session);
63
+ }
64
+ export function approvalDeclaredCapabilities(session) {
65
+ const caps = sessions.get(session);
66
+ return { ...(caps?.elicitation_form || caps?.elicitation_url ? { elicitation: {
67
+ ...(caps.elicitation_form ? { form: {} } : {}), ...(caps.elicitation_url ? { url: {} } : {}),
68
+ } } : {}), extensions: caps?.mcp_app
69
+ ? { [MCP_APP_EXTENSION]: { mimeTypes: [MCP_APP_MIME_TYPE] } } : {} };
70
+ }
56
71
  /**
57
72
  * Declared-capability probe for this in-process session. `appsAllowed: false` removes the Apps
58
73
  * tier from the choice (flag off / client not allowlisted), so a lower tier can still be used.
@@ -72,7 +87,7 @@ export function installApprovalCapabilityProbe(session) {
72
87
  const previous = session.oninitialized;
73
88
  session.oninitialized = () => {
74
89
  const info = object(session.getClientVersion?.());
75
- rememberApprovalCapabilities(session, session.getClientCapabilities(), info?.name);
90
+ rememberApprovalCapabilities(session, session.getClientCapabilities(), info?.name, info?.version);
76
91
  previous?.call(session);
77
92
  };
78
93
  }
@@ -0,0 +1,34 @@
1
+ export declare const ELICITATION_AUTO_ACCEPT_SUSPECT_MS = 2000;
2
+ export type ElicitationEvidence = ReturnType<typeof elicitationEvidence>;
3
+ export type DecisionEvidence = {
4
+ answer_latency_ms: number;
5
+ auto_accept_suspected: boolean;
6
+ channel: 'mcp_elicitation';
7
+ };
8
+ export declare function elicitationEvidence(input: {
9
+ promptShownAt: string;
10
+ answeredAt: string;
11
+ action: 'accept' | 'decline';
12
+ content?: unknown;
13
+ requestId?: string | number;
14
+ clientName?: unknown;
15
+ clientVersion?: unknown;
16
+ capabilities?: unknown;
17
+ userAgent?: unknown;
18
+ }): {
19
+ prompt_shown_at: string;
20
+ answered_at: string;
21
+ answer_latency_ms: number;
22
+ auto_accept_suspected: boolean;
23
+ client_name?: string | undefined;
24
+ client_version?: string | undefined;
25
+ client_capabilities: {
26
+ elicitation_form: boolean;
27
+ elicitation_url: boolean;
28
+ apps: boolean;
29
+ };
30
+ response_action: "accept" | "decline";
31
+ response_content_keys: string[];
32
+ user_agent?: string | undefined;
33
+ jsonrpc_request_id_hash?: string | undefined;
34
+ };
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { probeApprovalCapabilities } from './approval-capabilities.js';
3
+ export const ELICITATION_AUTO_ACCEPT_SUSPECT_MS = 2000;
4
+ // Metadata is client-declared, never proof of a human. Reject obvious private text.
5
+ const metadata = (value, max) => typeof value === 'string' && !/[@\r\n]|bearer\s|\b(?:sk_|snk_|eyJ)/i.test(value)
6
+ ? value.slice(0, max) : undefined;
7
+ export function elicitationEvidence(input) {
8
+ const latency = Math.max(0, Math.trunc(Date.parse(input.answeredAt) - Date.parse(input.promptShownAt)));
9
+ const capabilities = probeApprovalCapabilities(input.capabilities);
10
+ const name = metadata(input.clientName, 200), version = metadata(input.clientVersion, 100);
11
+ const userAgent = metadata(input.userAgent, 120);
12
+ return {
13
+ prompt_shown_at: input.promptShownAt, answered_at: input.answeredAt,
14
+ answer_latency_ms: latency,
15
+ auto_accept_suspected: input.action === 'accept' && latency < ELICITATION_AUTO_ACCEPT_SUSPECT_MS,
16
+ ...(name ? { client_name: name } : {}), ...(version ? { client_version: version } : {}),
17
+ client_capabilities: { elicitation_form: capabilities.elicitation_form,
18
+ elicitation_url: capabilities.elicitation_url, apps: capabilities.mcp_app },
19
+ response_action: input.action,
20
+ response_content_keys: input.content && typeof input.content === 'object' && !Array.isArray(input.content)
21
+ ? Object.keys(input.content).filter(key => /^[A-Za-z_][A-Za-z0-9_]{0,119}$/.test(key)) : [],
22
+ ...(userAgent ? { user_agent: userAgent } : {}),
23
+ ...(input.requestId !== undefined ? {
24
+ jsonrpc_request_id_hash: createHash('sha256').update(String(input.requestId)).digest('hex').slice(0, 8),
25
+ } : {}),
26
+ };
27
+ }
@@ -5,7 +5,9 @@ export declare function isElicitationResponse(body: unknown): boolean;
5
5
  /** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
6
6
  * No decision can enter this path via a tools/call, model text, or nested tool arguments.
7
7
  */
8
- export declare function recordElicitationResponse(body: unknown, identity: HumanDecisionIdentity): Promise<void>;
8
+ export declare function recordElicitationResponse(body: unknown, identity: HumanDecisionIdentity, metadata?: {
9
+ userAgent?: string | null;
10
+ }): Promise<void>;
9
11
  /** Insert before sending, using the exact wire id (a UUID, since each stateless SDK starts
10
12
  * its request counter at zero). Poll the persisted prompt; never await a response on the old
11
13
  * SDK Server, because the client's answer necessarily arrives at a fresh Server via POST.
@@ -1,3 +1,4 @@
1
+ import { elicitationEvidence } from './approval-elicitation-evidence.js';
1
2
  import { ELICITATION_HOLD_MS } from './approval-elicitation.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { approvalStore } from './tester-requests-store.js';
@@ -16,7 +17,8 @@ export function isElicitationResponse(body) {
16
17
  /** Auth has already run. Unknown and malformed responses are deliberately indistinguishable.
17
18
  * No decision can enter this path via a tools/call, model text, or nested tool arguments.
18
19
  */
19
- export async function recordElicitationResponse(body, identity) {
20
+ export async function recordElicitationResponse(body, identity, metadata = {}) {
21
+ const answeredAt = new Date().toISOString();
20
22
  const message = object(body);
21
23
  if (!isElicitationResponse(message) || message?.jsonrpc !== '2.0' ||
22
24
  (typeof message.id !== 'string' && typeof message.id !== 'number') ||
@@ -55,6 +57,36 @@ export async function recordElicitationResponse(body, identity) {
55
57
  });
56
58
  if (rpcError || data !== true)
57
59
  return;
60
+ // Evidence is additive. Neither metadata lookup nor audit failure can undo the decision.
61
+ try {
62
+ const { data: declared, error: declarationError } = await client.from('mcp_client_capabilities')
63
+ .select('client_name, client_version, elicitation_form, elicitation_url, mcp_app')
64
+ .eq('oauth_token_id', identity.oauthTokenId).gt('expires_at', answeredAt).maybeSingle();
65
+ const info = declarationError ? null : declared;
66
+ const capabilities = { elicitation: {
67
+ ...(info?.elicitation_form === true ? { form: {} } : {}),
68
+ ...(info?.elicitation_url === true ? { url: {} } : {}),
69
+ }, extensions: info?.mcp_app === true
70
+ ? { 'io.modelcontextprotocol/ui': { mimeTypes: ['text/html;profile=mcp-app'] } } : {} };
71
+ // Absent support must not become the legacy elicitation:{} form declaration.
72
+ if (!info?.elicitation_form && !info?.elicitation_url)
73
+ delete capabilities.elicitation;
74
+ const { error: auditError } = await client.from('audit_log').insert({
75
+ project_id: prompt.project_id, actor_type: 'user', actor_id: identity.actorId,
76
+ action: 'approval.elicitation_answered', resource_type: 'approval', resource_id: prompt.approval_id,
77
+ details: { channel: 'mcp_elicitation', ...elicitationEvidence({
78
+ promptShownAt: new Date(prompt.created_at).toISOString(), answeredAt,
79
+ action: result.action, content: result.content, requestId: message.id,
80
+ clientName: info?.client_name, clientVersion: info?.client_version,
81
+ capabilities, userAgent: metadata.userAgent,
82
+ }) },
83
+ });
84
+ if (auditError)
85
+ console.warn('[approval_evidence]', { reason: 'audit_write_failed' });
86
+ }
87
+ catch {
88
+ console.warn('[approval_evidence]', { reason: 'audit_write_failed' });
89
+ }
58
90
  }
59
91
  catch {
60
92
  // Never reveal whether a prompt exists, and never fall back to non-atomic REST writes.
@@ -1,3 +1,4 @@
1
+ import { type ElicitationEvidence } from './approval-elicitation-evidence.js';
1
2
  import { type HumanDecisionIdentity } from './approval-human-eligibility.js';
2
3
  import type { ElicitRequest } from '@modelcontextprotocol/sdk/types.js';
3
4
  import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
@@ -9,6 +10,7 @@ type HumanCallback = {
9
10
  interactionId: string;
10
11
  connectionId: string;
11
12
  decision: Decision;
13
+ evidence?: ElicitationEvidence;
12
14
  }): Promise<boolean>;
13
15
  };
14
16
  /** Injected at authenticated transport startup, never taken from tool arguments. */
@@ -1,10 +1,11 @@
1
+ import { elicitationEvidence } from './approval-elicitation-evidence.js';
1
2
  import { isHumanRequestableAdminTool } from './admin-tools.js';
2
3
  import { humanApprovalEligible } from './approval-human-eligibility.js';
3
4
  import { AsyncLocalStorage } from 'node:async_hooks';
4
5
  import { randomUUID } from 'node:crypto';
5
6
  import { approvalDisplayFact, buildApprovalPreview, isEmailApprovalAction, isHumanApprovalChannel } from '@sendinel/domain';
6
7
  import { approvalStore } from './tester-requests-store.js';
7
- import { approvalAppClientAllowed, approvalClientName, approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
8
+ import { approvalAppClientAllowed, approvalClientName, approvalClientVersion, approvalDeclaredCapabilities, approval_channel_available, probeApprovalCapabilities } from './approval-capabilities.js';
8
9
  // Keyed by the SDK Server, not the McpServer wrapper: other installers (the Apps tier) wrap
9
10
  // McpServer in a Proxy, which changes the wrapper's identity but never the connection's Server.
10
11
  const sessions = new WeakMap();
@@ -100,7 +101,9 @@ export async function elicitApproval(result, frozen, projectId) {
100
101
  }
101
102
  const callback = context.createHumanDecision({ approvalId: result.approval_id, projectId,
102
103
  connectionId: context.connectionId, userId: context.userId, channel: 'mcp_elicitation' });
104
+ const promptShownAt = new Date().toISOString();
103
105
  const response = await Promise.race([context.server.server.elicitInput(params, options), stopped]);
106
+ const answeredAt = new Date().toISOString();
104
107
  if (controller.signal.aborted)
105
108
  return exit(endReason ?? 'request_aborted');
106
109
  // Only the client's action is a decision. Extra content is untrusted and ignored.
@@ -108,7 +111,12 @@ export async function elicitApproval(result, frozen, projectId) {
108
111
  return exit('client_cancelled');
109
112
  const decision = response.action === 'accept' ? 'approve' : 'reject';
110
113
  const recorded = await Promise.race([callback.complete({ interactionId: callback.interactionId,
111
- connectionId: context.connectionId, decision }), stopped]);
114
+ connectionId: context.connectionId, decision, evidence: elicitationEvidence({
115
+ promptShownAt, answeredAt, action: response.action, content: response.content,
116
+ requestId: context.requestId, clientName: approvalClientName(context.server.server),
117
+ clientVersion: approvalClientVersion(context.server.server),
118
+ capabilities: approvalDeclaredCapabilities(context.server.server),
119
+ }) }), stopped]);
112
120
  return recorded ? { ...result, decision_status: decision === 'approve' ? 'approved' : 'rejected' } : exit('decision_refused');
113
121
  }
114
122
  const elicitationId = randomUUID();
@@ -14,6 +14,7 @@ export const APPROVAL_EXEMPT = {
14
14
  mark_agent_message: "changes only the bound mailbox message status; reversible and sends nothing",
15
15
  abuse_monitor_override: "admin-only override of Sendinel abuse monitoring on the caller own project; no outward send",
16
16
  add_blackout_date: "adds a no-send date; reduces sending",
17
+ check_link_domain: "records the link-domain status after checking DNS, ownership and the certificate; links switch on only after Vercel and a TLS handshake both prove it, and sends nothing itself",
17
18
  add_campaign_branch: "adds branching config to a campaign; recreatable and sends nothing itself",
18
19
  add_campaign_step: "adds an email to a campaign; sends only when the campaign is activated (gated)",
19
20
  add_subscriber: "adds one contact; contact count limits apply and nothing is sent",
@@ -1,3 +1,4 @@
1
+ import type { ElicitationEvidence } from './approval-elicitation-evidence.js';
1
2
  /**
2
3
  * Server integration only. The transport creates this closure for one authenticated human
3
4
  * interaction and retains it outside the tool registry. Neither its id nor decision is a tool
@@ -15,5 +16,6 @@ export declare function createHumanApprovalCallback(binding: {
15
16
  interactionId: string;
16
17
  connectionId: string;
17
18
  decision: 'approve' | 'reject';
19
+ evidence?: ElicitationEvidence;
18
20
  }): Promise<boolean>;
19
21
  };
@@ -38,7 +38,7 @@ export function createHumanApprovalCallback(binding) {
38
38
  const { error: auditError } = await client.from('audit_log').insert({
39
39
  project_id: binding.projectId, actor_type: 'user', actor_id: binding.userId,
40
40
  action: 'approval.submitted', resource_type: 'approval', resource_id: binding.approvalId,
41
- details: { status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
41
+ details: { ...(binding.channel === 'mcp_elicitation' ? response.evidence : {}), status: response.decision === 'approve' ? 'approved' : 'rejected', channel: binding.channel,
42
42
  connection_id: binding.connectionId, interaction_id: interactionId, payload_hash: data.payload_hash },
43
43
  });
44
44
  if (auditError)
package/build/server.js CHANGED
@@ -105,7 +105,7 @@ const WRITE_TOOLS = new Set([
105
105
  'create_social_post', 'update_social_post', 'delete_social_post',
106
106
  'draft_social_reply', 'propose_social_reply', 'comment_on_post', 'reply_to_comment', 'request_social_reply',
107
107
  'create_draft', 'approve_draft', 'reject_draft',
108
- 'create_site', 'create_sender', 'update_site',
108
+ 'create_site', 'create_sender', 'update_site', 'set_link_domain', 'check_link_domain',
109
109
  'create_template', 'update_template', 'delete_template', 'restore_template_version', 'delete_provider_credential', 'translate_template',
110
110
  'delete_subscriber_data', 'identify_inactive', 'set_subscriber_tags',
111
111
  'update_hygiene_config',
@@ -4,6 +4,8 @@ import { logAudit } from '../audit.js';
4
4
  import { db } from '../db.js';
5
5
  import { getProjectId } from '../project.js';
6
6
  import { checkPlanLimit } from '../plan-limits.js';
7
+ import { withApproval } from '../lib/action-approvals.js';
8
+ import { callSendinelV1 } from '../lib/v1-client.js';
7
9
  import { dbToolError } from '../lib/db-error.js';
8
10
  const txt = (text) => ({ content: [{ type: 'text', text }] });
9
11
  const j = (data) => txt(JSON.stringify(data, null, 2));
@@ -107,6 +109,29 @@ async function createSender({ name, slug, domain_id, local_part, sending_domain,
107
109
  return j(data);
108
110
  }
109
111
  export function registerSiteTools(server) {
112
+ server.registerTool('set_link_domain', {
113
+ title: 'Set Link Domain',
114
+ description: 'Set a branded address for this site’s email links, such as links.example.com. Returns every DNS record, your DNS provider, setup steps and a plain status. Optionally publish all records through your connected Cloudflare account. One approval covers setup and optional DNS publishing.',
115
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
116
+ inputSchema: {
117
+ site_id: z.string().uuid(),
118
+ link_domain: z.string().min(1).describe('A subdomain for email links, such as links.example.com.'),
119
+ publish_dns: z.boolean().optional().describe('Publish the full record set using your connected Cloudflare account.'),
120
+ approval_id: z.string().uuid().optional().describe('Approval for this exact setup, including publish_dns.'),
121
+ },
122
+ }, withApproval('set_link_domain', async (args) => {
123
+ const result = await callSendinelV1('/api/v1/link-domain', { method: 'POST', body: args });
124
+ return result.ok ? j(result.data) : { ...j(result.error), isError: true };
125
+ }));
126
+ server.registerTool('check_link_domain', {
127
+ title: 'Check Link Domain',
128
+ description: 'Check whether this site’s branded email links are live. Returns every DNS record, your DNS provider, setup steps and a plain status. Checks DNS, ownership and the secure connection before enabling links.',
129
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
130
+ inputSchema: { site_id: z.string().uuid() },
131
+ }, async ({ site_id }) => {
132
+ const result = await callSendinelV1('/api/v1/link-domain', { method: 'GET', query: { site_id } });
133
+ return result.ok ? j(result.data) : { ...j(result.error), isError: true };
134
+ });
110
135
  server.registerTool('get_brand_voice', {
111
136
  title: 'Get Brand Voice',
112
137
  annotations: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.158",
3
+ "version": "1.0.160",
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",