@sendinel/mcp-server 1.0.159 → 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.
@@ -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;
@@ -371,6 +371,31 @@ function captureSnapshot(action, params) {
371
371
  * Returns the approval if approved, null if still pending, throws if rejected/expired.
372
372
  */
373
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 = {}) {
374
399
  const applied = registeredApproval.getStore();
375
400
  if (options.consume && applied?.phase === 'approved' && applied.id === approvalId && applied.action === options.action && applied.projectId === getProjectId()) {
376
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): 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();
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.159",
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",