mcp-zenskar 1.1.13 → 1.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-zenskar",
3
- "version": "1.1.13",
3
+ "version": "1.1.15",
4
4
  "description": "Model Context Protocol (MCP) server for Zenskar API - customer management, invoicing, and billing operations",
5
5
  "main": "src/server.js",
6
6
  "bin": {
@@ -2921,6 +2921,37 @@
2921
2921
  "prependBody": "## Contract Amendments\n\n"
2922
2922
  }
2923
2923
  },
2924
+ {
2925
+ "name": "getContractBillingCycles",
2926
+ "description": "Returns the contract's pre-computed billing cycles as a list of {name, start_date, end_date, bill_for_date, billing_cycle_start_day}. REQUIRED before calling generateInvoice. Workflow: (1) call this tool, (2) pick the cycle entry whose date range covers the period you want to invoice, (3) pass that entry's `start_date` → `from_date`, `end_date` → `to_date`, `bill_for_date`, `billing_cycle_start_day` to generateInvoice — copy the values verbatim, no math. The backend matches `bill_for_date` exactly against this list; arbitrary dates produce empty invoice arrays.",
2927
+ "args": [
2928
+ {
2929
+ "name": "contractId",
2930
+ "description": "The unique identifier (UUID) of the contract.",
2931
+ "type": "string",
2932
+ "required": true,
2933
+ "position": "path"
2934
+ },
2935
+ {
2936
+ "name": "limit",
2937
+ "description": "Maximum number of cycles to return (default 10). Increase if the contract spans many years.",
2938
+ "type": "integer",
2939
+ "required": false,
2940
+ "default": 10,
2941
+ "position": "query"
2942
+ }
2943
+ ],
2944
+ "requestTemplate": {
2945
+ "url": "/contracts/{contractId}/billingCycles",
2946
+ "method": "GET",
2947
+ "headers": {
2948
+ "Content-Type": "application/json"
2949
+ }
2950
+ },
2951
+ "responseTemplate": {
2952
+ "prependBody": "## Contract Billing Cycles\n\n"
2953
+ }
2954
+ },
2924
2955
  {
2925
2956
  "name": "createContractPhase",
2926
2957
  "description": "Add a new phase to an existing contract. Use this for add-on expansions, mid-contract changes, or creating distinct billing periods with different pricing. Phase type enum: active, pause, trial.",
@@ -3261,7 +3292,7 @@
3261
3292
  },
3262
3293
  {
3263
3294
  "name": "generateInvoice",
3264
- "description": "STOP read this before calling. Granularity is PER PHASE, NOT per product. ONE call generates ONE invoice covering ALL products active in the (contract, customer, from_date, to_date) window — every product becomes a line item on the same invoice. NEVER iterate over products. Iterate over PHASES only. CORRECT: a 2-phase contract with 2 products per phaseexactly 2 calls (one per phase), producing 2 invoices each with 2 line items. WRONG: 4 calls (one per product per phase) produces 4 fragmented duplicate invoices, which is the #1 bug reported on this tool. Before calling, fetch the contract's phases via getContractById and use each phase's start_date / end_date as from_date / to_date. Use for billing replay or on-demand invoice generation.",
3295
+ "description": "Generate an invoice for a single contract+customer over a billing window.\n\nGranularity is PER PHASE, NOT per product. ONE call generates ONE invoice covering ALL products active in the (contract, customer, from_date, to_date) window — every product becomes a line item on the same invoice. NEVER iterate over products.\n\nMANDATORY PRE-STEP: call `getContractBillingCycles` first to fetch the list of valid {start_date, end_date, bill_for_date, billing_cycle_start_day} entries for this contract. Pick ONE entry. Copy its `start_date` `from_date`, `end_date` `to_date`, `bill_for_date`, `billing_cycle_start_day` verbatim. DO NOT compute these from contract.start_date, pricing.billing_period, or human intuition the backend matches `bill_for_date` exactly against the pre-computed list.\n\nDate fields accept ISO-8601 strings (e.g. `\"2026-04-28T00:00:00Z\"`) or integer UNIX seconds. Prefer pasting ISO strings verbatim from getContractBillingCycles output the server converts to unix internally before sending to the API.\n\nKnown failure modes:\n • `{invoice_ids: []}` `bill_for_date` did not match any cycle. Re-fetch billingCycles and copy the exact value.\n • $0 invoice → bill_for_date/billing_cycle_start_day omitted entirely.\n • InvoiceAlreadyGeneratedForThisBillingPeriod an approved invoice already exists for this cycle.",
3265
3296
  "args": [
3266
3297
  {
3267
3298
  "name": "contract_id",
@@ -3279,15 +3310,29 @@
3279
3310
  },
3280
3311
  {
3281
3312
  "name": "from_date",
3282
- "description": "Start date of the billing period as a UNIX timestamp in seconds (e.g. 1709251200 for 2024-03-01). Must be an integer, NOT a date string.",
3283
- "type": "number",
3313
+ "description": "Start of billing period. Accepts ISO-8601 string (e.g. `2026-04-28T00:00:00Z`) or UNIX seconds integer. PREFER ISO string copied verbatim from getContractBillingCycles.start_date — avoids timezone bugs.",
3314
+ "type": "datetime",
3284
3315
  "required": true,
3285
3316
  "position": "body"
3286
3317
  },
3287
3318
  {
3288
3319
  "name": "to_date",
3289
- "description": "End date of the billing period as a UNIX timestamp in seconds (e.g. 1711929600 for 2024-03-31). Must be an integer, NOT a date string.",
3290
- "type": "number",
3320
+ "description": "End of billing period. Accepts ISO-8601 string or UNIX seconds integer. PREFER ISO string copied verbatim from getContractBillingCycles.end_date.",
3321
+ "type": "datetime",
3322
+ "required": true,
3323
+ "position": "body"
3324
+ },
3325
+ {
3326
+ "name": "bill_for_date",
3327
+ "description": "REQUIRED. Anchors which billing cycle this invoice belongs to. Accepts ISO-8601 string or UNIX seconds. MUST match exactly one of the `bill_for_date` values returned by getContractBillingCycles — pasting the ISO string verbatim is the safest path. Arbitrary dates produce `invoice_ids: []`.",
3328
+ "type": "datetime",
3329
+ "required": true,
3330
+ "position": "body"
3331
+ },
3332
+ {
3333
+ "name": "billing_cycle_start_day",
3334
+ "description": "REQUIRED. Day of month (1-31) anchoring the recurring billing cycle. Fetch from the contract or plan's billing_period; if unsure, use the day-of-month of contract.start_date. Omitting this causes the API to return a $0 invoice because no billing cycle matches.",
3335
+ "type": "integer",
3291
3336
  "required": true,
3292
3337
  "position": "body"
3293
3338
  }
package/src/server.js CHANGED
@@ -683,6 +683,11 @@ function convertArgsToZodSchema(args) {
683
683
  zodType = z.record(z.any());
684
684
  } else if (arg.type === 'array') {
685
685
  zodType = z.array(z.any());
686
+ } else if (arg.type === 'datetime') {
687
+ // Accept ISO-8601 string or numeric unix seconds. Backend Pydantic
688
+ // datetime fields parse both. Lets the LLM copy ISO strings directly
689
+ // from getContractBillingCycles output instead of converting to unix.
690
+ zodType = z.union([z.string(), z.number()]);
686
691
  } else {
687
692
  zodType = z.string();
688
693
  }
@@ -713,6 +718,10 @@ function convertArgsToZodSchema(args) {
713
718
  // Add approval support for human-in-the-loop workflow
714
719
  approval: z.object({
715
720
  approved: z.boolean(),
721
+ // Single-use token issued by server in the approval_required response.
722
+ // Without this in the schema, Zod strips it from __userContext.approval
723
+ // and consumeApprovalToken() always sees undefined → infinite re-approval loop.
724
+ token: z.string().optional(),
716
725
  modifiedArguments: z.record(z.any()).optional(),
717
726
  originalArguments: z.record(z.any()).optional(),
718
727
  toolName: z.string().optional()
@@ -829,9 +838,26 @@ async function executeAPICall(tool, args) {
829
838
  // Build request body
830
839
  let body = null;
831
840
  if (method !== 'GET' && method !== 'DELETE' && tool.args) {
841
+ // Pydantic datetime fields on the Zenskar backend accept both ISO strings
842
+ // and unix int — but downstream code paths assume naive UTC datetimes
843
+ // (.astimezone() on aware datetimes raises). The UI normalises everything
844
+ // to unix int via dayjs.utc(...).unix(); we do the same here so the LLM
845
+ // can pass either form without producing a 500.
846
+ const coerceDatetime = (v) => {
847
+ if (typeof v === 'number') return Number.isFinite(v) ? Math.floor(v) : v;
848
+ if (typeof v === 'string') {
849
+ const t = Date.parse(v);
850
+ return Number.isFinite(t) ? Math.floor(t / 1000) : v;
851
+ }
852
+ return v;
853
+ };
832
854
  const bodyParams = {};
833
855
  tool.args.forEach(arg => {
834
856
  if (arg.position === 'body' && cleanArgs[arg.name] !== undefined) {
857
+ if (arg.type === 'datetime') {
858
+ bodyParams[arg.name] = coerceDatetime(cleanArgs[arg.name]);
859
+ return;
860
+ }
835
861
  if (tool.name === 'ingestRawMetricEvent' && arg.name === 'event') {
836
862
  const eventPayload = normalizeUsageEventPayload(cleanArgs[arg.name]);
837
863
  if (eventPayload && typeof eventPayload === 'object' && !Array.isArray(eventPayload)) {
@@ -992,10 +1018,19 @@ async function executeAPICall(tool, args) {
992
1018
  throw new Error('Authorization is required. Set ZENSKAR_AUTH_TOKEN (JWT) or ZENSKAR_API_KEY env var.');
993
1019
  }
994
1020
 
995
- // Add any other headers from user context
1021
+ // Add any other headers from user context (case-insensitive collision check).
1022
+ // Object keys cannot duplicate across casings within the same object, so the
1023
+ // Set only needs to be built once from the existing `headers`.
1024
+ // Filter prototype-pollution keys: `userContext.headers` may originate from
1025
+ // a JSON payload where an attacker could set `__proto__` to mutate the
1026
+ // local `headers` prototype.
996
1027
  if (userContext?.headers) {
1028
+ const existingLower = new Set(Object.keys(headers).map(k => k.toLowerCase()));
1029
+ const POISON_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
997
1030
  Object.keys(userContext.headers).forEach(key => {
998
- if (userContext.headers[key] && !headers[key.toLowerCase()]) {
1031
+ if (POISON_KEYS.has(key)) return;
1032
+ if (!Object.prototype.hasOwnProperty.call(userContext.headers, key)) return;
1033
+ if (userContext.headers[key] && !existingLower.has(key.toLowerCase())) {
999
1034
  headers[key] = userContext.headers[key];
1000
1035
  }
1001
1036
  });
@@ -1118,73 +1153,131 @@ async function executeAPICall(tool, args) {
1118
1153
  // scenarios where an LLM fabricates `approval.approved=true` to bypass the dialog.
1119
1154
  // Tokens are single-use and expire after 5 minutes.
1120
1155
  const APPROVAL_TOKEN_TTL_MS = 5 * 60 * 1000;
1121
- const approvalTokens = new Map(); // token -> {toolName, issuedAt, expiresAt}
1156
+ const APPROVAL_TOKEN_CLEANUP_MS = 60 * 1000;
1157
+ const approvalTokens = new Map(); // token -> {toolName, issuedAt, expiresAt, args}
1158
+
1159
+ // Deep clone via structuredClone when available, JSON fallback otherwise.
1160
+ // Approval args may include nested objects (addresses, pricing_data) that
1161
+ // executeAPICall mutates downstream; a shallow spread would share refs.
1162
+ function deepCloneArgs(value) {
1163
+ if (value == null) return value;
1164
+ if (typeof structuredClone === 'function') {
1165
+ try { return structuredClone(value); } catch (_) { /* fall through to JSON */ }
1166
+ }
1167
+ return JSON.parse(JSON.stringify(value));
1168
+ }
1122
1169
 
1123
- function issueApprovalToken(toolName) {
1170
+ function issueApprovalToken(toolName, args) {
1124
1171
  const token = uuidv4();
1125
1172
  const issuedAt = Date.now();
1173
+ // Snapshot args at issue time so the second invocation can restore them
1174
+ // even when the host does not echo them at the top level (e.g. Claude
1175
+ // Desktop's approval dialog only echoes __userContext.approval).
1176
+ // Note: `args` here is the initial tool invocation. generateApprovalRequest
1177
+ // only runs when checkNeedsApproval returned true, which means there was no
1178
+ // valid approval block. We still strip __userContext defensively in case a
1179
+ // host replays a stale approval block alongside fresh top-level args.
1180
+ const snapshot = args ? deepCloneArgs(args) : {};
1181
+ delete snapshot.__userContext;
1126
1182
  approvalTokens.set(token, {
1127
1183
  toolName,
1128
1184
  issuedAt,
1129
- expiresAt: issuedAt + APPROVAL_TOKEN_TTL_MS
1185
+ expiresAt: issuedAt + APPROVAL_TOKEN_TTL_MS,
1186
+ args: snapshot
1130
1187
  });
1131
- // Opportunistic cleanup of expired tokens.
1132
- for (const [t, entry] of approvalTokens) {
1133
- if (entry.expiresAt < issuedAt) approvalTokens.delete(t);
1134
- }
1135
1188
  return token;
1136
1189
  }
1137
1190
 
1138
1191
  function consumeApprovalToken(token, toolName) {
1139
- if (!token || typeof token !== 'string') return false;
1192
+ if (!token || typeof token !== 'string') return null;
1140
1193
  const entry = approvalTokens.get(token);
1141
- if (!entry) return false;
1142
- approvalTokens.delete(token); // single-use regardless of validity
1143
- if (entry.toolName !== toolName) return false;
1144
- if (entry.expiresAt < Date.now()) return false;
1145
- return true;
1194
+ if (!entry) return null;
1195
+ // Single-use: delete on first lookup regardless of toolName/expiry. A wrong
1196
+ // toolName here means either a replay attack or a host bug; either way the
1197
+ // token should not survive. Legitimate users get a fresh token via re-approval.
1198
+ approvalTokens.delete(token);
1199
+ if (entry.toolName !== toolName) return null;
1200
+ if (entry.expiresAt < Date.now()) return null;
1201
+ return entry;
1146
1202
  }
1147
1203
 
1148
- // Function to check if tool needs approval
1204
+ // Periodic sweep so expired tokens (and the args they snapshot) don't linger
1205
+ // in memory past their TTL even when no new approval is issued.
1206
+ setInterval(() => {
1207
+ const now = Date.now();
1208
+ for (const [t, entry] of approvalTokens) {
1209
+ if (entry.expiresAt < now) approvalTokens.delete(t);
1210
+ }
1211
+ }, APPROVAL_TOKEN_CLEANUP_MS).unref();
1212
+
1213
+ // Pure resolver: decides which arg source wins for an approved re-invoke.
1214
+ // Returns { args, source } where args is the object to apply (always non-null
1215
+ // for valid tokens), and source names the tier that won.
1216
+ //
1217
+ // SECURITY MODEL: the token snapshot stored at issue time is the canonical
1218
+ // record of what the user approved. We DO NOT trust top-level args on a
1219
+ // re-invoke — a malicious LLM holding a valid token could otherwise swap
1220
+ // in arbitrary path/body params (e.g. a different invoiceId) and still pass
1221
+ // the gate. The only host-supplied override accepted is
1222
+ // `approval.modifiedArguments`, which represents user edits performed inside
1223
+ // the approval dialog. Those edits MUST be re-validated by the caller
1224
+ // against the tool schema before the API call is executed.
1225
+ function resolveApprovedArgs(approval, tokenEntry) {
1226
+ const nonEmptyObj = (obj) =>
1227
+ obj && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length > 0;
1228
+
1229
+ if (nonEmptyObj(approval.modifiedArguments)) {
1230
+ return { args: approval.modifiedArguments, source: 'modifiedArguments' };
1231
+ }
1232
+ return { args: tokenEntry.args, source: 'tokenSnapshot' };
1233
+ }
1234
+
1235
+ // Replace `args` contents with `resolvedArgs` while preserving __userContext.
1236
+ // Kept separate from the resolver so the decision logic stays pure/testable.
1237
+ function applyResolvedArgs(args, resolvedArgs) {
1238
+ const savedUserContext = args.__userContext;
1239
+ Object.keys(args).forEach(key => {
1240
+ if (key !== '__userContext') delete args[key];
1241
+ });
1242
+ Object.assign(args, resolvedArgs);
1243
+ args.__userContext = savedUserContext;
1244
+ }
1245
+
1246
+ // Decides whether `tool` still needs approval given the current `args`.
1247
+ // As a side effect, when a valid approval token is present this function
1248
+ // also restores path/body params onto `args` from the resolver's chosen
1249
+ // tier. The mutation is deliberate — the MCP SDK passes `args` by
1250
+ // reference, and downstream callers (validateToolLimits, executeAPICall)
1251
+ // read from the same object. Argument resolution is itself pure, see
1252
+ // resolveApprovedArgs above.
1149
1253
  function checkNeedsApproval(tool, args) {
1150
1254
  if (!tool.needsApproval) {
1151
1255
  return false;
1152
1256
  }
1153
1257
 
1154
- // Check if this is a re-execution after approval. The host must echo the
1155
- // server-issued one-time token; bare `approved: true` is no longer trusted.
1156
1258
  const userContext = args.__userContext;
1157
1259
  const approval = userContext && userContext.approval;
1158
1260
  if (approval && approval.approved === true) {
1159
- if (consumeApprovalToken(approval.token, tool.name)) {
1160
- logger.info(`[${tool.name}] Approval token verified, executing with approved arguments`);
1161
-
1162
- // Replace current args with user-approved/modified arguments
1163
- const modifiedArgs = approval.modifiedArguments;
1164
- if (modifiedArgs) {
1165
- const savedUserContext = args.__userContext;
1166
- Object.keys(args).forEach(key => {
1167
- if (key !== '__userContext') {
1168
- delete args[key];
1169
- }
1170
- });
1171
- Object.assign(args, modifiedArgs);
1172
- args.__userContext = savedUserContext;
1173
- logger.info(`[${tool.name}] Using user-modified arguments:`, modifiedArgs);
1174
- }
1175
-
1176
- return false; // Skip approval, execute with approved args
1261
+ const tokenEntry = consumeApprovalToken(approval.token, tool.name);
1262
+ if (tokenEntry) {
1263
+ // Snapshot is canonical (see resolveApprovedArgs). The only host-supplied
1264
+ // override accepted is `approval.modifiedArguments` (user edits from the
1265
+ // approval dialog); those are re-validated against the tool schema in
1266
+ // the caller. Top-level args on the re-invoke are ignored to prevent a
1267
+ // valid token from acting as a blank check on path/body params.
1268
+ const { args: resolvedArgs, source } = resolveApprovedArgs(approval, tokenEntry);
1269
+ logger.info(`[${tool.name}] Approval token verified; arg source=${source}`);
1270
+ applyResolvedArgs(args, resolvedArgs);
1271
+ logger.info(`[${tool.name}] Restored arguments for approved execution:`, Object.keys(resolvedArgs));
1272
+ return false;
1177
1273
  }
1178
1274
  logger.warn(`[${tool.name}] Approval received without valid token (got: ${approval.token ? 'expired/mismatched' : 'missing'}); requiring re-approval`);
1179
1275
  // Fall through to issue a fresh approval_required response.
1180
1276
  }
1181
1277
 
1182
- // If needsApproval is a function, evaluate it
1183
1278
  if (typeof tool.needsApproval === 'function') {
1184
1279
  return tool.needsApproval(args);
1185
1280
  }
1186
-
1187
- // If it's a boolean true, always needs approval
1188
1281
  return tool.needsApproval === true;
1189
1282
  }
1190
1283
 
@@ -1193,7 +1286,7 @@ function generateApprovalRequest(tool, args) {
1193
1286
  const userContext = args.__userContext;
1194
1287
  const cleanArgs = { ...args };
1195
1288
  delete cleanArgs.__userContext;
1196
- const approvalToken = issueApprovalToken(tool.name);
1289
+ const approvalToken = issueApprovalToken(tool.name, args);
1197
1290
 
1198
1291
  return {
1199
1292
  type: 'approval_required',
@@ -1276,11 +1369,27 @@ function validateToolArgs(toolName, args) {
1276
1369
  }
1277
1370
 
1278
1371
  if (toolName === 'generateInvoice') {
1279
- if (!Number.isInteger(args.from_date)) errors.push("'from_date' must be an INTEGER UNIX timestamp (seconds), not a date string.");
1280
- if (!Number.isInteger(args.to_date)) errors.push("'to_date' must be an INTEGER UNIX timestamp (seconds), not a date string.");
1281
- if (Number.isInteger(args.from_date) && Number.isInteger(args.to_date) && args.to_date <= args.from_date) {
1372
+ const toUnix = (v) => {
1373
+ if (typeof v === 'number') return Number.isInteger(v) ? v : null;
1374
+ if (typeof v === 'string') {
1375
+ const t = Date.parse(v);
1376
+ return Number.isFinite(t) ? Math.floor(t / 1000) : null;
1377
+ }
1378
+ return null;
1379
+ };
1380
+ const from = toUnix(args.from_date);
1381
+ const to = toUnix(args.to_date);
1382
+ if (from === null) errors.push("'from_date' must be either an integer UNIX timestamp (seconds) or an ISO-8601 datetime string. Copy from getContractBillingCycles.start_date.");
1383
+ if (to === null) errors.push("'to_date' must be either an integer UNIX timestamp (seconds) or an ISO-8601 datetime string. Copy from getContractBillingCycles.end_date.");
1384
+ if (from !== null && to !== null && to <= from) {
1282
1385
  errors.push("'to_date' must be strictly greater than 'from_date'.");
1283
1386
  }
1387
+ if (toUnix(args.bill_for_date) === null) {
1388
+ errors.push("'bill_for_date' is REQUIRED (UNIX seconds or ISO-8601 string). Without it the API returns a $0 invoice. Copy from getContractBillingCycles.bill_for_date — do NOT compute.");
1389
+ }
1390
+ if (!Number.isInteger(args.billing_cycle_start_day) || args.billing_cycle_start_day < 1 || args.billing_cycle_start_day > 31) {
1391
+ errors.push("'billing_cycle_start_day' is REQUIRED and must be an integer 1-31. Copy from getContractBillingCycles.billing_cycle_start_day.");
1392
+ }
1284
1393
  }
1285
1394
 
1286
1395
  if (toolName === 'pauseContract') {
@@ -1391,7 +1500,31 @@ if (mcpConfig.tools && mcpConfig.tools.length > 0) {
1391
1500
  approvalRequest: approvalRequest
1392
1501
  };
1393
1502
  }
1394
-
1503
+
1504
+ // Re-validate after the approval gate. checkNeedsApproval may have
1505
+ // swapped path/body params with user-supplied `modifiedArguments`
1506
+ // (host dialog edits), which were not part of the pre-approval
1507
+ // validation pass. Bypass attempts via crafted modifiedArguments
1508
+ // get caught here.
1509
+ const postApprovalValidation = validateToolArgs(tool.name, args);
1510
+ if (!postApprovalValidation.valid) {
1511
+ logger.error(`[${tool.name}] Approved arguments failed validation:`, postApprovalValidation.errors);
1512
+ tokenUsageStatus = 'blocked';
1513
+ tokenUsageReason = `invalid_args_post_approval: ${postApprovalValidation.errors.join('; ')}`;
1514
+ return {
1515
+ content: [
1516
+ { type: "text", text: JSON.stringify({ type: 'invalid_arguments', toolName: tool.name, errors: postApprovalValidation.errors }, null, 2) },
1517
+ {
1518
+ type: "text",
1519
+ text: `ACTION_NOT_EXECUTED — INVALID_ARGUMENTS\n\nApproved arguments failed validation. The user-edited values from the approval dialog do not satisfy the tool schema:\n\n` +
1520
+ postApprovalValidation.errors.map((e, i) => `${i + 1}. ${e}`).join('\n') +
1521
+ `\n\nRe-issue the call with corrected arguments; a new approval gate will be required.`
1522
+ }
1523
+ ],
1524
+ isError: true
1525
+ };
1526
+ }
1527
+
1395
1528
  // Extract user context for token usage tracking
1396
1529
  const userId = userContext?.userId || 'unknown';
1397
1530
  const chatId = userContext?.chatId || null; // Use NULL for direct MCP calls
@@ -1442,7 +1575,8 @@ if (mcpConfig.tools && mcpConfig.tools.length > 0) {
1442
1575
  };
1443
1576
  }
1444
1577
 
1445
- // (validateToolArgs already ran pre-approval; no duplicate check here.)
1578
+ // (validateToolArgs ran pre-approval; post-approval re-validation
1579
+ // ran immediately after the approval gate. No third check here.)
1446
1580
 
1447
1581
  // Use adjusted args with enforced limits
1448
1582
  const adjustedArgs = limitsValidation.adjustedArgs;
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>app</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="./app.tsx"></script>
11
+ </body>
12
+ </html>