@sendinel/mcp-server 1.0.196 → 1.0.197

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);
@@ -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 and return the secret once.',
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
- // MCP API-key sessions can be unbound to a user, so they cannot prove an
993
- // acting org role. Admin keys must be created through the dashboard.
994
- if (scopes.includes('admin')) {
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: getProjectId(), name, slug: cleanSlug, key_hash: hash, key_prefix: prefix, scopes })
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
- return j({ key_id: data.id, name: data.name, slug: data.slug, secret });
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.196",
3
+ "version": "1.0.197",
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",