@sendinel/mcp-server 1.0.58 → 1.0.60

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.
@@ -649,9 +649,12 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
649
649
  .order('check_date', { ascending: false })
650
650
  .limit(1);
651
651
  const health = (healthRows ?? [])[0];
652
- const spfValid = health?.spf_valid ?? false;
653
- const dkimValid = health?.dkim_valid ?? false;
654
- const dmarcValid = health?.dmarc_valid ?? false;
652
+ const spfValid = health?.spf_valid ?? null; // null = not checked yet (#3904)
653
+ const spfFailed = spfValid === false;
654
+ const dkimValid = health?.dkim_valid ?? null; // null = not checked yet (#3904)
655
+ const dkimFailed = dkimValid === false;
656
+ const dmarcValid = health?.dmarc_valid ?? null; // null = not checked yet (#3904)
657
+ const dmarcFailed = dmarcValid === false;
655
658
  const warmupDay = health?.warmup_day ?? null;
656
659
  const warmupDailyLimit = health?.warmup_daily_limit ?? null;
657
660
  // 3. Fetch recent 7-day email_log stats
@@ -686,9 +689,9 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
686
689
  issues.push({ type: 'high_complaint', severity: 'critical', detail: `Complaint rate ${complaintRate}% exceeds 0.3% threshold` });
687
690
  else if (complaintRate > 0.1)
688
691
  issues.push({ type: 'elevated_complaint', severity: 'high', detail: `Complaint rate ${complaintRate}% approaching danger zone` });
689
- if (!spfValid || !dkimValid)
690
- issues.push({ type: 'dns_invalid', severity: 'high', detail: `Missing DNS: ${!spfValid ? 'SPF' : ''}${!spfValid && !dkimValid ? ' + ' : ''}${!dkimValid ? 'DKIM' : ''}` });
691
- if (!dmarcValid)
692
+ if (spfFailed || dkimFailed)
693
+ issues.push({ type: 'dns_invalid', severity: 'high', detail: `Missing DNS: ${spfFailed ? 'SPF' : ''}${spfFailed && dkimFailed ? ' + ' : ''}${dkimFailed ? 'DKIM' : ''}` });
694
+ if (dmarcFailed)
692
695
  issues.push({ type: 'no_dmarc', severity: 'medium', detail: 'DMARC record missing or invalid' });
693
696
  if (warmupDay != null && warmupDay < 42 && bounceRate > 3) {
694
697
  issues.push({ type: 'warmup_risk', severity: 'high', detail: `Domain on warmup day ${warmupDay} with ${bounceRate}% bounce rate — warmup at risk` });
@@ -711,9 +714,9 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
711
714
  immediate.push('Review recent bounce reasons in suppression log');
712
715
  }
713
716
  // DNS fixes
714
- if (!spfValid)
717
+ if (spfFailed)
715
718
  immediate.push('Add SPF record for sending domain');
716
- if (!dkimValid) {
719
+ if (dkimFailed) {
717
720
  const { data: dkimSite } = await db()
718
721
  .from('sites')
719
722
  .select('provider')
@@ -722,7 +725,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
722
725
  .maybeSingle();
723
726
  immediate.push(dkimRemediationText(dkimSite?.provider));
724
727
  }
725
- if (!dmarcValid)
728
+ if (dmarcFailed)
726
729
  shortTerm.push('Add DMARC record (start with p=none for monitoring)');
727
730
  // List hygiene
728
731
  if (bounceRate > 2 || complained > 0) {
@@ -738,7 +741,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
738
741
  shortTerm.push('Reduce daily send volume by 50% for at least 48 hours');
739
742
  }
740
743
  // Re-verification
741
- if (!spfValid || !dkimValid) {
744
+ if (spfFailed || dkimFailed) {
742
745
  shortTerm.push('Re-verify domain after DNS changes (use dns-verify API)');
743
746
  }
744
747
  // Monitoring steps
@@ -753,11 +756,11 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
753
756
  }
754
757
  // 7. Compute health score (0-100)
755
758
  let healthScore = 100;
756
- if (!spfValid)
759
+ if (spfFailed)
757
760
  healthScore -= 15;
758
- if (!dkimValid)
761
+ if (dkimFailed)
759
762
  healthScore -= 15;
760
- if (!dmarcValid)
763
+ if (dmarcFailed)
761
764
  healthScore -= 5;
762
765
  if (bounceRate > 8)
763
766
  healthScore -= 30;
@@ -780,7 +783,7 @@ Return ONLY valid JSON (no markdown fences): { "topics": [{ "title": "...", "rat
780
783
  estimatedRecovery = '7-14 days';
781
784
  else if (bounceRate > 5 || complaintRate > 0.3)
782
785
  estimatedRecovery = '5-7 days';
783
- else if (!spfValid || !dkimValid)
786
+ else if (spfFailed || dkimFailed)
784
787
  estimatedRecovery = '2-3 days';
785
788
  else if (bounceRate > 2)
786
789
  estimatedRecovery = '3-5 days';
@@ -14,4 +14,9 @@ export declare function matchesToolQuery(entry: {
14
14
  group: string;
15
15
  description: string;
16
16
  }, query: string): boolean;
17
+ /**
18
+ * Keys in args that the target tool's JSON Schema does not declare. Returns [] when the schema
19
+ * has no `properties` or allows additional properties, so free-form tools keep working.
20
+ */
21
+ export declare function findUnknownArgKeys(inputSchema: unknown, args: Record<string, unknown>): string[];
17
22
  export declare function registerGatewayTools(server: McpServer, options: GatewayOptions): void;
@@ -54,6 +54,38 @@ async function buildCatalog(options) {
54
54
  });
55
55
  return { client, entries, byName: new Map(entries.map((entry) => [entry.name, entry])) };
56
56
  }
57
+ /** Top-level keys invoke_tool accepts; anything else is rejected by name. */
58
+ function unknownKeyMessage(keys) {
59
+ const list = keys.map((k) => `'${k}'`).join(', ');
60
+ const hinted = keys.some((k) => ARGS_LOOKALIKES.has(k.toLowerCase()));
61
+ return `Unknown key(s) on invoke_tool: ${list}${hinted ? "; did you mean 'args'?" : ''} Valid keys: name, args (alias: arguments).`;
62
+ }
63
+ const ARGS_LOOKALIKES = new Set(['args', 'argument', 'params', 'input', 'parameters']);
64
+ function declaredProperties(inputSchema) {
65
+ if (!inputSchema || typeof inputSchema !== 'object')
66
+ return null;
67
+ const schema = inputSchema;
68
+ if (schema.additionalProperties)
69
+ return null;
70
+ const props = schema.properties;
71
+ if (!props || typeof props !== 'object' || Array.isArray(props))
72
+ return null;
73
+ return Object.keys(props);
74
+ }
75
+ /**
76
+ * Keys in args that the target tool's JSON Schema does not declare. Returns [] when the schema
77
+ * has no `properties` or allows additional properties, so free-form tools keep working.
78
+ */
79
+ export function findUnknownArgKeys(inputSchema, args) {
80
+ const declared = declaredProperties(inputSchema);
81
+ if (!declared)
82
+ return [];
83
+ return Object.keys(args).filter((k) => !declared.includes(k));
84
+ }
85
+ function unknownArgsMessage(name, inputSchema, unknown) {
86
+ const valid = (declaredProperties(inputSchema) ?? []).sort();
87
+ return `Unknown argument(s) for ${name}: ${unknown.map((k) => `'${k}'`).join(', ')}. Valid arguments: ${valid.length ? valid.join(', ') : '(none)'}. Call describe_tool for the full schema.`;
88
+ }
57
89
  export function registerGatewayTools(server, options) {
58
90
  let catalogPromise = null;
59
91
  const getCatalog = () => (catalogPromise ??= buildCatalog(options));
@@ -95,17 +127,33 @@ export function registerGatewayTools(server, options) {
95
127
  });
96
128
  server.registerTool('invoke_tool', {
97
129
  title: 'Invoke Tool',
98
- description: 'Execute one permitted Sendinel MCP tool by name with the same scope and approval enforcement as calling it directly. Use search_tools and describe_tool first.',
130
+ description: 'Execute one permitted Sendinel MCP tool by name with the same scope and approval enforcement as calling it directly. Pass the target tool arguments under args (alias: arguments). Unknown top-level keys and arguments the target tool does not declare are rejected. Use search_tools and describe_tool first.',
99
131
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
100
- inputSchema: {
132
+ // Loose object so unknown top-level keys reach the handler (a raw shape would silently strip them).
133
+ inputSchema: z.looseObject({
101
134
  name: z.string().min(1).describe('Tool name to invoke'),
102
- args: z.record(z.string(), z.unknown()).optional().default({}).describe('Arguments for the target tool'),
103
- },
104
- }, async ({ name, args }) => {
135
+ args: z.record(z.string(), z.unknown()).optional().describe('Arguments for the target tool'),
136
+ arguments: z.record(z.string(), z.unknown()).optional().describe('Alias for args (the MCP-standard field name). Provide args or arguments, not both.'),
137
+ }),
138
+ }, async (input) => {
139
+ const { name, args, arguments: argumentsAlias, ...rest } = input;
140
+ const unknownKeys = Object.keys(rest);
141
+ if (unknownKeys.length > 0) {
142
+ return notFound(unknownKeyMessage(unknownKeys));
143
+ }
144
+ if (args !== undefined && argumentsAlias !== undefined && JSON.stringify(args) !== JSON.stringify(argumentsAlias)) {
145
+ return notFound("Both 'args' and 'arguments' were provided with different values. Provide either 'args' or 'arguments', not both.");
146
+ }
147
+ const effective = args ?? argumentsAlias ?? {};
105
148
  const catalog = await getCatalog();
106
- if (!catalog.byName.has(name)) {
149
+ const entry = catalog.byName.get(name);
150
+ if (!entry) {
107
151
  return notFound(`Tool not found or not permitted: ${name}`);
108
152
  }
109
- return await catalog.client.callTool({ name, arguments: args ?? {} });
153
+ const unknownArgs = findUnknownArgKeys(entry.inputSchema, effective);
154
+ if (unknownArgs.length > 0) {
155
+ return notFound(unknownArgsMessage(name, entry.inputSchema, unknownArgs));
156
+ }
157
+ return await catalog.client.callTool({ name, arguments: effective });
110
158
  });
111
159
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sendinel/mcp-server",
3
- "version": "1.0.58",
3
+ "version": "1.0.60",
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",