@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 +2 -2
- package/build/lib/action-approvals.d.ts +8 -0
- package/build/lib/action-approvals.js +26 -0
- package/build/lib/approval-actions.js +1 -0
- package/build/lib/approval-capabilities.d.ts +3 -1
- package/build/lib/approval-capabilities.js +17 -2
- package/build/lib/approval-elicitation-evidence.d.ts +34 -0
- package/build/lib/approval-elicitation-evidence.js +27 -0
- package/build/lib/approval-elicitation-relay.d.ts +3 -1
- package/build/lib/approval-elicitation-relay.js +33 -1
- package/build/lib/approval-elicitation.d.ts +2 -0
- package/build/lib/approval-elicitation.js +10 -2
- package/build/lib/approval-exempt.js +1 -0
- package/build/lib/approval-human-callback.d.ts +2 -0
- package/build/lib/approval-human-callback.js +1 -1
- package/build/server.js +1 -1
- package/build/tools/sites.js +25 -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 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` |
|
|
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 } : {}) };
|
|
@@ -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
|
|
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
|
|
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',
|
package/build/tools/sites.js
CHANGED
|
@@ -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.
|
|
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",
|