@sendinel/mcp-server 1.0.196 → 1.0.198
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.
|
@@ -132,6 +132,7 @@ export declare function withPreviewApproval<A extends object, R extends ToolResu
|
|
|
132
132
|
export declare function markNotExecuted<R extends object>(result: R): R;
|
|
133
133
|
/** HTTP statuses that mean the request was refused before it changed anything. */
|
|
134
134
|
export declare function isPreExecutionStatus(status: number | undefined): boolean;
|
|
135
|
+
export declare function currentExecutingApprovalId(): string | undefined;
|
|
135
136
|
export declare function withApproval<A extends object, R extends ToolResult>(action: string, handler: (args: A, extra?: unknown) => Promise<R> | R, options?: {
|
|
136
137
|
display?: (args: A) => Promise<ApprovalDisplay> | ApprovalDisplay;
|
|
137
138
|
registeredRequestOnly?: boolean;
|
|
@@ -668,6 +668,12 @@ export function markNotExecuted(result) {
|
|
|
668
668
|
export function isPreExecutionStatus(status) {
|
|
669
669
|
return status !== undefined && [0, 400, 401, 403, 404, 422, 429, 501].includes(status);
|
|
670
670
|
}
|
|
671
|
+
// Server-owned context: the approval id a legacy withApproval handler is executing under.
|
|
672
|
+
// Never read from tool arguments.
|
|
673
|
+
const executingApproval = new AsyncLocalStorage();
|
|
674
|
+
export function currentExecutingApprovalId() {
|
|
675
|
+
return executingApproval.getStore()?.id;
|
|
676
|
+
}
|
|
671
677
|
export function withApproval(action, handler, options = {}) {
|
|
672
678
|
return async (args, extra) => {
|
|
673
679
|
const { approval_id, ...params } = args;
|
|
@@ -709,7 +715,7 @@ export function withApproval(action, handler, options = {}) {
|
|
|
709
715
|
return registeredApproval.run({ id: approval_id, action, projectId: getProjectId(), phase: decision.tier === 'autonomous' ? 'level' : 'approved',
|
|
710
716
|
...(decision.tier === 'autonomous' ? { levelUserId: requestContext()?.actorId ?? undefined } : {}), decidedBy, decisionChannel }, () => handler(args, extra));
|
|
711
717
|
}
|
|
712
|
-
const result = await handler(params, extra);
|
|
718
|
+
const result = await executingApproval.run({ id: approval_id }, () => handler(params, extra));
|
|
713
719
|
// Keep the atomic claim across the outbound hop. Only a definitive pre-acceptance
|
|
714
720
|
// failure may release it; transport errors and unknown outcomes can already have sent.
|
|
715
721
|
const releasable = action === 'send_test_email' ? testSendNotAccepted(result) : notExecutedResults.has(result);
|
package/build/tools/data.js
CHANGED
|
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|
|
4
4
|
import { adminDb, db } from '../db.js';
|
|
5
5
|
import { decrypt, encrypt } from '../crypto.js';
|
|
6
6
|
import { normalizeActiveCampaignAccountUrl } from '../lib/url-validation.js';
|
|
7
|
-
import { assertChannelCanExecute, checkApproval, currentChannelContext, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
7
|
+
import { approvalPageUrl, assertChannelCanExecute, checkApproval, currentChannelContext, currentExecutingApprovalId, requestApproval, withApproval } from '../lib/action-approvals.js';
|
|
8
8
|
import { getProjectId } from '../project.js';
|
|
9
9
|
import { logAudit } from '../audit.js';
|
|
10
10
|
import { dbToolError, deleteBlockedToolResult } from '../lib/db-error.js';
|
|
@@ -17,6 +17,35 @@ function generateApiKey() {
|
|
|
17
17
|
prefix: secret.slice(0, 8),
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
|
+
const MAX_ACTIVE_API_KEYS = 10;
|
|
21
|
+
const API_KEY_CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
22
|
+
const API_KEY_ALLOWED_SCOPES = new Set(['read', 'write']);
|
|
23
|
+
/** Returns a plain error message, or null when the key request is valid. Never echoes secrets. */
|
|
24
|
+
async function validateCreateApiKeyInput(scopes, toolGroups) {
|
|
25
|
+
if (scopes.includes('admin')) {
|
|
26
|
+
return 'Admin-scope API keys must be created in the dashboard by an org owner or admin.';
|
|
27
|
+
}
|
|
28
|
+
const badScope = scopes.find((scope) => !API_KEY_ALLOWED_SCOPES.has(scope));
|
|
29
|
+
if (badScope !== undefined)
|
|
30
|
+
return `Invalid scope "${badScope}". Only "read" and "write" are allowed here.`;
|
|
31
|
+
if (toolGroups.length === 0)
|
|
32
|
+
return null;
|
|
33
|
+
// Lazy: a static import of server.js is an import cycle (server imports every tool module).
|
|
34
|
+
const { ALL_TOOL_GROUPS } = await import('../server.js');
|
|
35
|
+
const badGroup = toolGroups.find((group) => !ALL_TOOL_GROUPS.includes(group));
|
|
36
|
+
if (badGroup !== undefined)
|
|
37
|
+
return `Unknown tool group "${badGroup}". Valid groups: ${ALL_TOOL_GROUPS.join(', ')}.`;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
// Bad scopes or groups fail before a person is asked to approve anything.
|
|
41
|
+
function createApiKeyWithEarlyValidation(gated) {
|
|
42
|
+
return async (args, extra) => {
|
|
43
|
+
const invalid = await validateCreateApiKeyInput(args.scopes ?? ['read'], args.tool_groups ?? []);
|
|
44
|
+
if (invalid)
|
|
45
|
+
return j({ error: invalid });
|
|
46
|
+
return gated(args, extra);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
20
49
|
function generateCustomWebhookSecret() {
|
|
21
50
|
return randomBytes(32).toString('hex');
|
|
22
51
|
}
|
|
@@ -981,29 +1010,80 @@ export function registerDataTools(server) {
|
|
|
981
1010
|
idempotentHint: false,
|
|
982
1011
|
openWorldHint: false,
|
|
983
1012
|
},
|
|
984
|
-
description: 'Create an API key
|
|
1013
|
+
description: 'Create an API key (read or write scope, optionally limited to tool_groups). The secret is NEVER returned here: it is shown once to a person on the claim_url page (open for 30 minutes). Ask the person to open claim_url and hand the key to the agent runtime.',
|
|
985
1014
|
inputSchema: {
|
|
986
1015
|
approval_id: z.string().uuid().optional().describe('Get this by calling once without it, then have a human approve the returned approve_url. Re-call with only approval_id to run the action.'),
|
|
987
1016
|
name: z.string().min(1),
|
|
988
1017
|
slug: z.string().optional(),
|
|
989
1018
|
scopes: z.array(z.string()).optional().default(['read']),
|
|
1019
|
+
tool_groups: z.array(z.string()).optional().default([]).describe('Tool groups this key may use. Empty means all groups.'),
|
|
990
1020
|
},
|
|
991
|
-
}, withApproval('create_api_key', async ({ name, slug, scopes = ['read'] }) => {
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
return j({ error: 'Admin-scope API keys must be created in the dashboard by an org owner or admin.' });
|
|
996
|
-
}
|
|
1021
|
+
}, createApiKeyWithEarlyValidation(withApproval('create_api_key', async ({ name, slug, scopes = ['read'], tool_groups = [] }) => {
|
|
1022
|
+
const invalid = await validateCreateApiKeyInput(scopes, tool_groups);
|
|
1023
|
+
if (invalid)
|
|
1024
|
+
return j({ error: invalid });
|
|
997
1025
|
const { secret, hash, prefix } = generateApiKey();
|
|
998
1026
|
const cleanSlug = slug?.trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50) || null;
|
|
1027
|
+
const approvalId = currentExecutingApprovalId();
|
|
1028
|
+
if (!approvalId)
|
|
1029
|
+
return j({ error: 'This key needs an approved request so a person can claim it. Call without approval_id first.' });
|
|
1030
|
+
const projectId = getProjectId();
|
|
1031
|
+
const { count, error: countError } = await db().from('api_keys')
|
|
1032
|
+
.select('id', { count: 'exact', head: true })
|
|
1033
|
+
.eq('project_id', projectId)
|
|
1034
|
+
.is('revoked_at', null);
|
|
1035
|
+
if (countError)
|
|
1036
|
+
return j(dbToolError(countError));
|
|
1037
|
+
if ((count ?? 0) >= MAX_ACTIVE_API_KEYS) {
|
|
1038
|
+
return j({ error: `Workspace API key limit reached (${MAX_ACTIVE_API_KEYS} active keys). Revoke one first.` });
|
|
1039
|
+
}
|
|
999
1040
|
const { data, error } = await db().from('api_keys')
|
|
1000
|
-
.insert({ project_id:
|
|
1041
|
+
.insert({ project_id: projectId, name, slug: cleanSlug, key_hash: hash, key_prefix: prefix, scopes, tool_groups })
|
|
1001
1042
|
.select('id, name, slug')
|
|
1002
1043
|
.single();
|
|
1003
1044
|
if (error)
|
|
1004
1045
|
return j(dbToolError(error));
|
|
1005
|
-
|
|
1006
|
-
|
|
1046
|
+
const { data: project } = await adminDb().schema('email').from('projects').select('org_id').eq('id', projectId).maybeSingle();
|
|
1047
|
+
const expiresAt = new Date(Date.now() + API_KEY_CLAIM_TTL_MS).toISOString();
|
|
1048
|
+
let stored = false;
|
|
1049
|
+
try {
|
|
1050
|
+
const { error: claimError } = await adminDb().schema('email').from('api_key_claims').insert({
|
|
1051
|
+
project_id: projectId,
|
|
1052
|
+
org_id: project?.org_id ?? null,
|
|
1053
|
+
key_id: data.id,
|
|
1054
|
+
approval_id: approvalId,
|
|
1055
|
+
secret_enc: encrypt(secret),
|
|
1056
|
+
expires_at: expiresAt,
|
|
1057
|
+
});
|
|
1058
|
+
stored = !claimError;
|
|
1059
|
+
}
|
|
1060
|
+
catch {
|
|
1061
|
+
stored = false;
|
|
1062
|
+
}
|
|
1063
|
+
if (!stored) {
|
|
1064
|
+
// Nobody can ever see this secret, so the key must not stay live.
|
|
1065
|
+
await db().from('api_keys').update({ revoked_at: new Date().toISOString() }).eq('project_id', projectId).eq('id', data.id);
|
|
1066
|
+
return j({ error: 'The key could not be prepared for a person to claim, so it was revoked. Ask for a new one.' });
|
|
1067
|
+
}
|
|
1068
|
+
return j({
|
|
1069
|
+
key_id: data.id,
|
|
1070
|
+
name: data.name,
|
|
1071
|
+
slug: data.slug,
|
|
1072
|
+
key_prefix: prefix,
|
|
1073
|
+
scopes,
|
|
1074
|
+
tool_groups,
|
|
1075
|
+
claim_url: approvalPageUrl(approvalId),
|
|
1076
|
+
expires_at: expiresAt,
|
|
1077
|
+
message: 'Ask the person to open claim_url. The secret is shown there once and is never in this reply.',
|
|
1078
|
+
});
|
|
1079
|
+
}, {
|
|
1080
|
+
display: ({ name, scopes = ['read'], tool_groups = [] }) => ({
|
|
1081
|
+
key_name: name,
|
|
1082
|
+
access: scopes.includes('write') ? 'Read & write' : 'Read only',
|
|
1083
|
+
tool_groups: tool_groups.length ? tool_groups.join(', ') : 'all tools',
|
|
1084
|
+
note: 'The key will be shown once, to you, on this page.',
|
|
1085
|
+
}),
|
|
1086
|
+
})));
|
|
1007
1087
|
server.registerTool('revoke_api_key', {
|
|
1008
1088
|
title: 'Revoke API Key',
|
|
1009
1089
|
annotations: {
|
package/build/tools/warmup.js
CHANGED
|
@@ -17,8 +17,8 @@ export function registerWarmupTools(server) {
|
|
|
17
17
|
inputSchema: { site_id: z.string().uuid() },
|
|
18
18
|
}, async ({ site_id }) => {
|
|
19
19
|
const { data, error } = await db().from('domain_health')
|
|
20
|
-
.select('warmup_day, warmup_daily_limit, total_sent, check_date')
|
|
21
|
-
.eq('project_id', getProjectId())
|
|
20
|
+
.select('warmup_day, warmup_daily_limit, total_sent, check_date, sites!inner(project_id)')
|
|
21
|
+
.eq('sites.project_id', getProjectId())
|
|
22
22
|
.eq('site_id', site_id)
|
|
23
23
|
.order('check_date', { ascending: false })
|
|
24
24
|
.limit(1)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sendinel/mcp-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.198",
|
|
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",
|