mcp-zenskar 1.1.11 → 1.1.12

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.11",
3
+ "version": "1.1.12",
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": {
@@ -586,7 +586,7 @@
586
586
  },
587
587
  {
588
588
  "name": "payInvoice",
589
- "description": "Initiate payment for an invoice using a payload. MONEY-MOVING ACTION — only call when the user explicitly authorizes payment for this specific invoice.",
589
+ "description": "Initiate payment for an invoice using a payload. MONEY-MOVING ACTION — only call when the user explicitly authorizes payment for this specific invoice. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
590
590
  "needsApproval": true,
591
591
  "approvalConfig": {
592
592
  "title": "Pay Invoice",
@@ -976,7 +976,7 @@
976
976
  },
977
977
  {
978
978
  "name": "createRawMetric",
979
- "description": "Create a new Usage Event schema. In backend/API terminology this resource is called a raw metric. This defines the schema used for ingesting usage events.",
979
+ "description": "Create a new Usage Event schema. In backend/API terminology this resource is called a raw metric. This defines the schema used for ingesting usage events. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
980
980
  "needsApproval": true,
981
981
  "approvalConfig": {
982
982
  "title": "Create Usage Event Schema",
@@ -1840,7 +1840,7 @@
1840
1840
  },
1841
1841
  {
1842
1842
  "name": "createEntitlement",
1843
- "description": "Creates a new entitlement in the system with various attributes including name, description, type, units, and product association.",
1843
+ "description": "Creates a new entitlement in the system with various attributes including name, description, type, units, and product association. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
1844
1844
  "needsApproval": true,
1845
1845
  "approvalConfig": {
1846
1846
  "title": "Create New Entitlement",
@@ -1917,7 +1917,7 @@
1917
1917
  },
1918
1918
  {
1919
1919
  "name": "approveInvoice",
1920
- "description": "Approves an invoice. This changes the invoice status to approved and may trigger billing processes.",
1920
+ "description": "Approve an invoice; sets status to approved. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
1921
1921
  "needsApproval": true,
1922
1922
  "approvalConfig": {
1923
1923
  "title": "Approve Invoice",
@@ -1969,7 +1969,7 @@
1969
1969
  },
1970
1970
  {
1971
1971
  "name": "recogniseRevenue",
1972
- "description": "Recognizes revenue for the organization up to a specified end date. This triggers revenue recognition processing for all eligible transactions.",
1972
+ "description": "Recognize revenue for the organization up to a specified end_date. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
1973
1973
  "needsApproval": true,
1974
1974
  "approvalConfig": {
1975
1975
  "title": "Recognise Revenue",
@@ -2003,7 +2003,7 @@
2003
2003
  },
2004
2004
  {
2005
2005
  "name": "createBusinessEntity",
2006
- "description": "Creates a new business entity in the system with various attributes including name, address, contact details, tax configuration, and logo.",
2006
+ "description": "Creates a new business entity in the system with various attributes including name, address, contact details, tax configuration, and logo. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
2007
2007
  "needsApproval": true,
2008
2008
  "approvalConfig": {
2009
2009
  "title": "Create New Business Entity",
@@ -2316,7 +2316,7 @@
2316
2316
  },
2317
2317
  {
2318
2318
  "name": "ingestRawMetricEvent",
2319
- "description": "Ingest a Usage Event for the specified resource slug. Backend/API terminology may also call the target resource a raw metric.",
2319
+ "description": "Ingest a Usage Event for the specified resource slug. Backend/API terminology may also call the target resource a raw metric. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
2320
2320
  "needsApproval": true,
2321
2321
  "approvalConfig": {
2322
2322
  "title": "Ingest Usage Event",
@@ -2730,7 +2730,7 @@
2730
2730
  },
2731
2731
  {
2732
2732
  "name": "deleteContract",
2733
- "description": "Permanently delete a DRAFT contract. ONLY works on contracts with status='draft'. Do NOT call on ACTIVE, EXPIRED, or PAUSED contracts. DESTRUCTIVE: removes all phases, products, and pricing associations. NEVER call as automatic recovery from another failed operation (e.g., do not retry as a fallback after expireContract or voidInvoice fails). ONLY call when the user explicitly says 'delete' or 'remove' for THIS draft contract. If a non-destructive action fails, surface the error verbatim and stop.",
2733
+ "description": "Permanently delete a DRAFT contract. ONLY works on contracts with status='draft'. Do NOT call on ACTIVE, EXPIRED, or PAUSED contracts. DESTRUCTIVE: removes all phases, products, and pricing associations. NEVER call as automatic recovery from another failed operation (e.g., do not retry as a fallback after expireContract or voidInvoice fails). ONLY call when the user explicitly says 'delete' or 'remove' for THIS draft contract. If a non-destructive action fails, surface the error verbatim and stop. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
2734
2734
  "needsApproval": true,
2735
2735
  "approvalConfig": {
2736
2736
  "title": "Delete Draft Contract",
@@ -2978,7 +2978,7 @@
2978
2978
  },
2979
2979
  {
2980
2980
  "name": "expireContract",
2981
- "description": "Expire an ACTIVE or PAUSED contract by adjusting its end_date. Allowed status transitions: ACTIVE→EXPIRED, PAUSED→EXPIRED. EXPIRED is terminal — calling on an already-expired contract returns 400; do NOT retry, do NOT escalate to deleteContract or any other destructive tool as a fallback. Verify contract.status via getContractById before calling. Idempotency: NO. The contract expires at end of day 23:59:59.999999.",
2981
+ "description": "Expire an ACTIVE or PAUSED contract by adjusting its end_date. Allowed status transitions: ACTIVE→EXPIRED, PAUSED→EXPIRED. EXPIRED is terminal — calling on an already-expired contract returns 400; do NOT retry, do NOT escalate to deleteContract or any other destructive tool as a fallback. Verify contract.status via getContractById before calling. Idempotency: NO. The contract expires at end of day 23:59:59.999999. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
2982
2982
  "needsApproval": true,
2983
2983
  "approvalConfig": {
2984
2984
  "title": "Expire Contract",
@@ -3020,7 +3020,7 @@
3020
3020
  },
3021
3021
  {
3022
3022
  "name": "voidInvoice",
3023
- "description": "Void an invoice. ONLY works on approved or paid invoices with invoice_total == 0 (zero-balance, typically after a full credit-note offset). For non-zero approved invoices, issue a credit note via createInvoiceCreditNote first to bring the balance to zero, then void. If the call returns INVOICE_CANNOT_BE_VOIDED, surface that error verbatim — do NOT retry, do NOT escalate to deleteInvoice.",
3023
+ "description": "Void an invoice. ONLY works on approved or paid invoices with invoice_total == 0 (zero-balance, typically after a full credit-note offset). For non-zero approved invoices, issue a credit note via createInvoiceCreditNote first to bring the balance to zero, then void. If the call returns INVOICE_CANNOT_BE_VOIDED, surface that error verbatim — do NOT retry, do NOT escalate to deleteInvoice. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
3024
3024
  "needsApproval": true,
3025
3025
  "approvalConfig": {
3026
3026
  "title": "Void Invoice",
@@ -3054,7 +3054,7 @@
3054
3054
  },
3055
3055
  {
3056
3056
  "name": "deleteInvoice",
3057
- "description": "Permanently delete a draft invoice. ONLY works on status='draft'. For approved invoices, use voidInvoice (with caveats) or createInvoiceCreditNote — do NOT call deleteInvoice on approved invoices. DESTRUCTIVE: record removed permanently. ONLY call when the user explicitly says 'delete' for THIS invoice; never call as automatic recovery from another failed operation.",
3057
+ "description": "Permanently delete a draft invoice. ONLY works on status='draft'. For approved invoices, use voidInvoice (with caveats) or createInvoiceCreditNote — do NOT call deleteInvoice on approved invoices. DESTRUCTIVE: record removed permanently. ONLY call when the user explicitly says 'delete' for THIS invoice; never call as automatic recovery from another failed operation. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
3058
3058
  "needsApproval": true,
3059
3059
  "approvalConfig": {
3060
3060
  "title": "Delete Invoice",
@@ -3125,7 +3125,7 @@
3125
3125
  },
3126
3126
  {
3127
3127
  "name": "generateInvoice",
3128
- "description": "Generate ONE invoice covering ALL products active in the (contract, customer, from_date, to_date) tuple. The invoice contains every applicable product as separate line items — do NOT call once per product. Granularity is per-phase, NOT per-product. To bill a multi-phase contract, call ONCE per phase using that phase's start/end as from_date/to_date — e.g., a 2-phase contract with 2 products = 2 calls (yielding 2 invoices, each with 2 line items), NOT 4 calls. Iterating per product produces fragmented duplicate invoices and is incorrect. Use for billing replay or on-demand invoice generation.",
3128
+ "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 phase → exactly 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.",
3129
3129
  "args": [
3130
3130
  {
3131
3131
  "name": "contract_id",
@@ -3381,7 +3381,7 @@
3381
3381
  },
3382
3382
  {
3383
3383
  "name": "deleteManualPayment",
3384
- "description": "Permanently delete a manual payment record. Only works on payments in an eligible status for deletion. DESTRUCTIVE — only call when the user explicitly says 'delete' for THIS payment; never as automatic recovery from another failed operation.",
3384
+ "description": "Permanently delete a manual payment record. Only works on payments in an eligible status for deletion. DESTRUCTIVE — only call when the user explicitly says 'delete' for THIS payment; never as automatic recovery from another failed operation. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
3385
3385
  "needsApproval": true,
3386
3386
  "approvalConfig": {
3387
3387
  "title": "Delete Manual Payment",
@@ -4216,7 +4216,7 @@
4216
4216
  },
4217
4217
  {
4218
4218
  "name": "createProductPricing",
4219
- "description": "Create a new pricing configuration for a product. The pricing_data object is a discriminated union — the 'pricing_type' field determines which pricing model schema. IMPORTANT: ask the user explicitly for currency, quantity_type (fixed|metered), and unit amount BEFORE calling. Do not silently default any of these. unit_amount is in MAJOR currency units (float), e.g. 3 means $3, not 300 cents.",
4219
+ "description": "Create a new pricing configuration for a product. MANDATORY pre-call checklist — ask the user for ALL of these and do NOT default any silently: (1) currency (ISO 4217), (2) pricing_type (per_unit|flat_fee|tiered|volume|percent|package|step|matrix), (3) unit_amount in MAJOR currency units (float — 3 means $3, NOT 300 cents), (4) quantity object with type (fixed|metered) and unit label (e.g. 'user', 'request') and either quantity (for fixed) or aggregate_id (for metered), (5) billing_period.cadence (ISO 8601 — 'P1M' monthly, 'P3M' quarterly, 'P1Y' yearly) and billing_period.offset. SKIPPING quantity OR billing_period causes the Zenskar UI to render 'Undefined- Every Undefined Undefined' for billing cadence and 0 for billing metric — that is the #1 bug reported on this tool.",
4220
4220
  "args": [
4221
4221
  {
4222
4222
  "name": "productId",
@@ -4248,16 +4248,16 @@
4248
4248
  },
4249
4249
  {
4250
4250
  "name": "quantity",
4251
- "description": "Quantity configuration (REQUIRED for usage-based pricing — ask the user). Top-level object, NOT inside pricing_data. Shape: {type: 'fixed'|'metered', quantity?: number, unit?: string, aggregate_id?: UUID}. 'fixed' means a static quantity (e.g. seats). 'metered' means consumption tracked via a billable metric — set aggregate_id to the billable-metric UUID. Without a quantity object the resulting pricing has no billing_metric and renders as 0 in the UI.",
4251
+ "description": "Quantity configuration (REQUIRED — ask the user; do not omit). Top-level object, NOT inside pricing_data. Shape: {type: 'fixed'|'metered', quantity?: number, unit?: string, aggregate_id?: UUID}. 'fixed' = static quantity (e.g. seats); also set 'unit' (label like 'user') and 'quantity' (number). 'metered' = consumption tracked via a billable metric; set aggregate_id to the billable-metric UUID. Omitting this object causes the UI to show 0 for billing metric.",
4252
4252
  "type": "object",
4253
- "required": false,
4253
+ "required": true,
4254
4254
  "position": "body"
4255
4255
  },
4256
4256
  {
4257
4257
  "name": "billing_period",
4258
- "description": "Billing cadence for this pricing. Object with 'cadence' (ISO 8601 duration, e.g. 'P1M' for monthly, 'P3M' for quarterly, 'P1Y' for annually) and optional 'offset'. Example: {cadence: 'P1M'}.",
4258
+ "description": "Billing cadence (REQUIRED — ask the user; do not omit). Object: {cadence: ISO-8601 duration ('P1M'=monthly, 'P3M'=quarterly, 'P1Y'=annually), offset: ISO-8601 duration ('P0D' for no offset, 'P1M' to bill 1 month after period start)}. Both fields needed. Example: {\"cadence\":\"P1M\",\"offset\":\"P0D\"}. Omitting this causes the UI to render 'Undefined- Every Undefined Undefined'.",
4259
4259
  "type": "object",
4260
- "required": false,
4260
+ "required": true,
4261
4261
  "position": "body"
4262
4262
  },
4263
4263
  {
@@ -4355,62 +4355,46 @@
4355
4355
  },
4356
4356
  {
4357
4357
  "name": "createPlan",
4358
- "description": "Create a new plan — a reusable contract template with products and pricing. Hits POST /plans (same store the Zenskar app's Plans page reads).",
4358
+ "description": "Create a new plan — a reusable contract template with phased pricing. Hits POST /plans (same Plan table the Zenskar app's PlansV2 page reads). The request body shape is NESTED, not flat: top-level fields are name, status, schedule, optional description, optional phases[]. Currency is set per-pricing inside phase.pricings[].pricing.pricing_data.currency, NOT at the top level. A plan with no phases is unusable in the UI — always include at least one phase. Plan starts as 'draft'; the user must publish it to make it active.",
4359
4359
  "args": [
4360
4360
  {
4361
4361
  "name": "name",
4362
- "description": "Name of the plan.",
4362
+ "description": "Name of the plan (required).",
4363
4363
  "type": "string",
4364
4364
  "required": true,
4365
4365
  "position": "body"
4366
4366
  },
4367
4367
  {
4368
- "name": "currency",
4369
- "description": "Three-letter ISO 4217 currency code (e.g., 'USD', 'EUR', 'GBP').",
4368
+ "name": "status",
4369
+ "description": "Plan status (required). Valid values: 'draft', 'active', 'archived'. New plans almost always start as 'draft' — the user publishes via the UI to activate.",
4370
4370
  "type": "string",
4371
4371
  "required": true,
4372
- "position": "body"
4372
+ "position": "body",
4373
+ "enum": [
4374
+ "draft",
4375
+ "active",
4376
+ "archived"
4377
+ ]
4373
4378
  },
4374
4379
  {
4375
- "name": "display_config",
4376
- "description": "Customer-facing display content. Object with fields like title, description, etc.",
4380
+ "name": "schedule",
4381
+ "description": "Plan-level schedule (required). Object: {duration: ISO-8601 e.g. 'P1Y'|'P1M', start_offset?: ISO-8601 e.g. 'P0D', trigger_type?: 'time_based'}. Example: {\"duration\":\"P1Y\",\"start_offset\":\"P0D\"}.",
4377
4382
  "type": "object",
4378
- "required": false,
4383
+ "required": true,
4379
4384
  "position": "body"
4380
4385
  },
4381
4386
  {
4382
- "name": "duration",
4383
- "description": "Billing period in ISO 8601 duration format (e.g., 'P1M' for monthly, 'P1Y' for yearly).",
4387
+ "name": "description",
4388
+ "description": "Optional plan description.",
4384
4389
  "type": "string",
4385
4390
  "required": false,
4386
4391
  "position": "body"
4387
4392
  },
4388
4393
  {
4389
- "name": "products",
4390
- "description": "Array of products to add to plan, each with product_id and pricing configuration.",
4394
+ "name": "phases",
4395
+ "description": "Array of plan phases (REQUIRED — must be non-empty). Each phase: {name: string (required), schedule: {duration, start_offset?, trigger_type?} (required), order: int (required, 0-indexed), description?: string, features?: CreateProductPricingRequestSchema (one-off phase-level pricing/features), pricings?: [{schedule, pricing_id?, product_id?, pricing?: CreateProductPricingRequestSchema, product?: CreateProductRequestSchema}] (per-product pricings)}. Each phase must have features OR a non-empty pricings array. Minimal example: [{\"name\":\"Phase 1\",\"schedule\":{\"duration\":\"P1Y\"},\"order\":0,\"features\":{\"pricing_data\":{\"pricing_type\":\"features\"}}}].",
4391
4396
  "type": "array",
4392
- "required": false,
4393
- "position": "body"
4394
- },
4395
- {
4396
- "name": "custom_attributes",
4397
- "description": "Custom attributes as key-value pairs.",
4398
- "type": "object",
4399
- "required": false,
4400
- "position": "body"
4401
- },
4402
- {
4403
- "name": "status",
4404
- "description": "Plan status. Default: 'draft'. Options: 'draft', 'active', 'archived'.",
4405
- "type": "string",
4406
- "required": false,
4407
- "position": "body"
4408
- },
4409
- {
4410
- "name": "business_entity_id",
4411
- "description": "Business entity ID for invoicing.",
4412
- "type": "string",
4413
- "required": false,
4397
+ "required": true,
4414
4398
  "position": "body"
4415
4399
  }
4416
4400
  ],
@@ -5137,7 +5121,7 @@
5137
5121
  },
5138
5122
  {
5139
5123
  "name": "deleteCustomer",
5140
- "description": "Permanently delete a customer by ID. DESTRUCTIVE and cannot be undone. The customer must not have active contracts or unpaid invoices. ONLY call when the user explicitly says 'delete' for THIS customer; never as automatic recovery from another failed operation.",
5124
+ "description": "Permanently delete a customer by ID. DESTRUCTIVE and cannot be undone. The customer must not have active contracts or unpaid invoices. ONLY call when the user explicitly says 'delete' for THIS customer; never as automatic recovery from another failed operation. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
5141
5125
  "needsApproval": true,
5142
5126
  "approvalConfig": {
5143
5127
  "title": "Delete Customer",
@@ -5171,7 +5155,7 @@
5171
5155
  },
5172
5156
  {
5173
5157
  "name": "deleteContact",
5174
- "description": "Permanently delete a contact by ID. DESTRUCTIVE — only call when the user explicitly says 'delete' for THIS contact; never as automatic recovery from another failed operation.",
5158
+ "description": "Permanently delete a contact by ID. DESTRUCTIVE — only call when the user explicitly says 'delete' for THIS contact; never as automatic recovery from another failed operation. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
5175
5159
  "needsApproval": true,
5176
5160
  "approvalConfig": {
5177
5161
  "title": "Delete Contact",
@@ -5205,7 +5189,7 @@
5205
5189
  },
5206
5190
  {
5207
5191
  "name": "deletePaymentMethod",
5208
- "description": "Permanently delete a payment method from a customer. DESTRUCTIVE — saved card/bank details will be removed; recurring auto-charges using this method will fail. Only call when the user explicitly says 'delete' for THIS payment method.",
5192
+ "description": "Permanently delete a payment method from a customer. DESTRUCTIVE — saved card/bank details will be removed; recurring auto-charges using this method will fail. Only call when the user explicitly says 'delete' for THIS payment method. Host enforces user confirmation via the approval gate; do NOT ask the user to re-confirm before calling.",
5209
5193
  "needsApproval": true,
5210
5194
  "approvalConfig": {
5211
5195
  "title": "Delete Payment Method",
@@ -5343,7 +5327,7 @@
5343
5327
  },
5344
5328
  {
5345
5329
  "name": "resumeContract",
5346
- "description": "Resume an actively-running pause on a contract. PRECONDITIONS: the pause's start_date must be strictly in the past (not today, not future). For pauses scheduled for a future start, or to set a future resume date, use 'editPauseContract' instead (sets the pause's end_date). The error 'pause phase not found' means no pause phase has yet started — NOT that no pause exists. If this call fails, surface the error verbatim and consider 'editPauseContract'; do NOT escalate to deleteContract or any destructive fallback.",
5330
+ "description": "Resume a contract that is currently in an actively-running pause. MANDATORY FLOW: (1) ASK the user 'What date should the contract resume from?' — never call without a known resume date even though this endpoint takes no body. (2) Call getContractById and check the pause phase's start_date. (3a) If pause start_date is strictly in the past (pause currently active) AND the user wants to resume immediately/today → call this endpoint. (3b) If pause start_date is today OR in the future, OR the user wants a specific future resume date → call 'editPauseContract' with end_date=<resume_date> instead. The error 'pause phase not found' does NOT mean no pause exists — it means no pause has yet started; on this error, automatically pivot to 'editPauseContract' with the resume date. Never escalate to deleteContract or any destructive fallback.",
5347
5331
  "args": [
5348
5332
  {
5349
5333
  "name": "contractId",
package/src/server.js CHANGED
@@ -1113,43 +1113,77 @@ async function executeAPICall(tool, args) {
1113
1113
  }
1114
1114
  }
1115
1115
 
1116
+ // One-time approval tokens. Server issues a token on the approval_required response;
1117
+ // the host must echo it back on the second invocation. Prevents prompt-injection
1118
+ // scenarios where an LLM fabricates `approval.approved=true` to bypass the dialog.
1119
+ // Tokens are single-use and expire after 5 minutes.
1120
+ const APPROVAL_TOKEN_TTL_MS = 5 * 60 * 1000;
1121
+ const approvalTokens = new Map(); // token -> {toolName, issuedAt, expiresAt}
1122
+
1123
+ function issueApprovalToken(toolName) {
1124
+ const token = uuidv4();
1125
+ const issuedAt = Date.now();
1126
+ approvalTokens.set(token, {
1127
+ toolName,
1128
+ issuedAt,
1129
+ expiresAt: issuedAt + APPROVAL_TOKEN_TTL_MS
1130
+ });
1131
+ // Opportunistic cleanup of expired tokens.
1132
+ for (const [t, entry] of approvalTokens) {
1133
+ if (entry.expiresAt < issuedAt) approvalTokens.delete(t);
1134
+ }
1135
+ return token;
1136
+ }
1137
+
1138
+ function consumeApprovalToken(token, toolName) {
1139
+ if (!token || typeof token !== 'string') return false;
1140
+ 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;
1146
+ }
1147
+
1116
1148
  // Function to check if tool needs approval
1117
1149
  function checkNeedsApproval(tool, args) {
1118
1150
  if (!tool.needsApproval) {
1119
1151
  return false;
1120
1152
  }
1121
-
1122
- // Check if this is a re-execution after approval
1153
+
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.
1123
1156
  const userContext = args.__userContext;
1124
- if (userContext && userContext.approval && userContext.approval.approved === true) {
1125
- logger.info(`[${tool.name}] Tool was approved by user, using modified arguments`);
1126
-
1127
- // Replace current args with user-approved/modified arguments
1128
- const modifiedArgs = userContext.approval.modifiedArguments;
1129
- if (modifiedArgs) {
1130
- // Clear existing tool args but keep __userContext
1131
- const savedUserContext = args.__userContext;
1132
- Object.keys(args).forEach(key => {
1133
- if (key !== '__userContext') {
1134
- delete args[key];
1135
- }
1136
- });
1137
-
1138
- // Apply user's modified arguments
1139
- Object.assign(args, modifiedArgs);
1140
- args.__userContext = savedUserContext;
1141
-
1142
- logger.info(`[${tool.name}] Using user-modified arguments:`, modifiedArgs);
1157
+ const approval = userContext && userContext.approval;
1158
+ 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
1143
1177
  }
1144
-
1145
- return false; // Skip approval, execute with approved args
1178
+ logger.warn(`[${tool.name}] Approval received without valid token (got: ${approval.token ? 'expired/mismatched' : 'missing'}); requiring re-approval`);
1179
+ // Fall through to issue a fresh approval_required response.
1146
1180
  }
1147
-
1181
+
1148
1182
  // If needsApproval is a function, evaluate it
1149
1183
  if (typeof tool.needsApproval === 'function') {
1150
1184
  return tool.needsApproval(args);
1151
1185
  }
1152
-
1186
+
1153
1187
  // If it's a boolean true, always needs approval
1154
1188
  return tool.needsApproval === true;
1155
1189
  }
@@ -1159,12 +1193,15 @@ function generateApprovalRequest(tool, args) {
1159
1193
  const userContext = args.__userContext;
1160
1194
  const cleanArgs = { ...args };
1161
1195
  delete cleanArgs.__userContext;
1162
-
1196
+ const approvalToken = issueApprovalToken(tool.name);
1197
+
1163
1198
  return {
1164
1199
  type: 'approval_required',
1165
1200
  toolName: tool.name,
1166
1201
  toolDescription: tool.description,
1167
1202
  arguments: cleanArgs,
1203
+ approvalToken,
1204
+ approvalTokenExpiresInSeconds: Math.floor(APPROVAL_TOKEN_TTL_MS / 1000),
1168
1205
  approvalConfig: tool.approvalConfig || {
1169
1206
  title: `Approve ${tool.name}`,
1170
1207
  description: `This action requires your approval: ${tool.description}`,
@@ -1184,6 +1221,76 @@ function generateApprovalRequest(tool, args) {
1184
1221
  };
1185
1222
  }
1186
1223
 
1224
+ // Tool-specific deep argument validation. Catches semantic gaps that JSON Schema
1225
+ // `required` cannot express (e.g. nested fields inside an `object` arg).
1226
+ // Return shape matches validateToolLimits: {valid, errors[]}.
1227
+ function validateToolArgs(toolName, args) {
1228
+ const errors = [];
1229
+
1230
+ if (toolName === 'createProductPricing') {
1231
+ const pd = args.pricing_data;
1232
+ if (!pd || typeof pd !== 'object') {
1233
+ errors.push("'pricing_data' is required and must be an object containing 'pricing_type' and 'currency'.");
1234
+ } else {
1235
+ if (!pd.pricing_type) errors.push("'pricing_data.pricing_type' is required (e.g. 'per_unit', 'flat_fee', 'tiered', 'volume', 'percent', 'package').");
1236
+ if (!pd.currency) errors.push("'pricing_data.currency' is required (ISO 4217, e.g. 'USD').");
1237
+ }
1238
+ const q = args.quantity;
1239
+ if (!q || typeof q !== 'object') {
1240
+ errors.push("'quantity' is required and must be an object: {type:'fixed'|'metered', quantity?, unit?, aggregate_id?}. Without it the UI shows 0 for billing_metric.");
1241
+ } else {
1242
+ if (q.type !== 'fixed' && q.type !== 'metered') errors.push("'quantity.type' must be exactly 'fixed' or 'metered'.");
1243
+ if (q.type === 'fixed' && (q.quantity == null || !q.unit)) errors.push("'quantity.type'='fixed' requires both 'quantity' (number) and 'unit' (label string).");
1244
+ if (q.type === 'metered' && !q.aggregate_id) errors.push("'quantity.type'='metered' requires 'aggregate_id' (UUID of the billable metric).");
1245
+ }
1246
+ const bp = args.billing_period;
1247
+ if (!bp || typeof bp !== 'object') {
1248
+ errors.push("'billing_period' is required and must be an object: {cadence:'P1M'|'P3M'|'P1Y'|..., offset:'P0D'|...}. Without it the UI renders 'Undefined- Every Undefined Undefined'.");
1249
+ } else {
1250
+ if (!bp.cadence) errors.push("'billing_period.cadence' is required (ISO-8601 duration: 'P1M'=monthly, 'P3M'=quarterly, 'P1Y'=yearly).");
1251
+ if (!bp.offset) errors.push("'billing_period.offset' is required (ISO-8601 duration, 'P0D' for no offset).");
1252
+ }
1253
+ }
1254
+
1255
+ if (toolName === 'createPlan') {
1256
+ if (!args.schedule || typeof args.schedule !== 'object' || !args.schedule.duration) {
1257
+ errors.push("'schedule' is required and must include 'duration' (ISO-8601, e.g. 'P1Y').");
1258
+ }
1259
+ if (!args.status) {
1260
+ errors.push("'status' is required: 'draft' | 'active' | 'archived'.");
1261
+ }
1262
+ const phases = args.phases;
1263
+ if (!Array.isArray(phases) || phases.length === 0) {
1264
+ errors.push("'phases' must be a non-empty array. A plan with no phases is unusable in PlansV2 — include at least one phase with name, schedule, order, and either features or pricings.");
1265
+ } else {
1266
+ phases.forEach((p, i) => {
1267
+ if (!p || typeof p !== 'object') { errors.push(`'phases[${i}]' must be an object.`); return; }
1268
+ if (!p.name) errors.push(`'phases[${i}].name' is required.`);
1269
+ if (!p.schedule || !p.schedule.duration) errors.push(`'phases[${i}].schedule.duration' is required (ISO-8601).`);
1270
+ if (typeof p.order !== 'number') errors.push(`'phases[${i}].order' is required (integer, 0-indexed).`);
1271
+ if (!p.features && (!Array.isArray(p.pricings) || p.pricings.length === 0)) {
1272
+ errors.push(`'phases[${i}]' has neither 'features' nor non-empty 'pricings' — phase will be empty in the UI.`);
1273
+ }
1274
+ });
1275
+ }
1276
+ }
1277
+
1278
+ 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) {
1282
+ errors.push("'to_date' must be strictly greater than 'from_date'.");
1283
+ }
1284
+ }
1285
+
1286
+ if (toolName === 'pauseContract') {
1287
+ if (!args.start_date) errors.push("'start_date' is required (ISO 8601).");
1288
+ if (!args.unpause_extension_policy) errors.push("'unpause_extension_policy' is required: 'extend' or 'overlap'.");
1289
+ }
1290
+
1291
+ return { valid: errors.length === 0, errors };
1292
+ }
1293
+
1187
1294
  // Helper to map API types to form field types
1188
1295
  function getFieldType(apiType) {
1189
1296
  switch (apiType) {
@@ -1229,38 +1336,62 @@ if (mcpConfig.tools && mcpConfig.tools.length > 0) {
1229
1336
  approvedInArgs: args.__userContext?.approved
1230
1337
  });
1231
1338
 
1232
- // Check if this tool needs approval and hasn't been approved yet
1339
+ // Validate args BEFORE the approval gate so users do not approve broken calls.
1340
+ const earlyArgValidation = validateToolArgs(tool.name, args);
1341
+ if (!earlyArgValidation.valid) {
1342
+ logger.error(`[${tool.name}] Tool execution blocked due to invalid arguments (pre-approval):`, earlyArgValidation.errors);
1343
+ tokenUsageStatus = 'blocked';
1344
+ tokenUsageReason = `invalid_args: ${earlyArgValidation.errors.join('; ')}`;
1345
+ const errorJson = {
1346
+ type: 'invalid_arguments',
1347
+ toolName: tool.name,
1348
+ errors: earlyArgValidation.errors
1349
+ };
1350
+ return {
1351
+ content: [
1352
+ { type: "text", text: JSON.stringify(errorJson, null, 2) },
1353
+ {
1354
+ type: "text",
1355
+ text: `ACTION_NOT_EXECUTED — INVALID_ARGUMENTS\n\nTool '${tool.name}' was NOT executed because the supplied arguments are invalid or incomplete:\n\n` +
1356
+ earlyArgValidation.errors.map((e, i) => `${i + 1}. ${e}`).join('\n') +
1357
+ `\n\nFix the arguments and call again. Do NOT report success to the user.`
1358
+ }
1359
+ ],
1360
+ isError: true
1361
+ };
1362
+ }
1363
+
1364
+ // Check if this tool needs approval and hasn't been approved yet.
1365
+ // checkNeedsApproval() validates the one-time token from
1366
+ // __userContext.approval.token; bare `approved: true` is not trusted.
1233
1367
  const needsApproval = checkNeedsApproval(tool, args);
1234
1368
  const userContext = args.__userContext;
1235
- const isApproved = userContext?.approved === true;
1236
-
1369
+
1237
1370
  logger.info(`[${tool.name}] Approval check:`, {
1238
1371
  needsApproval,
1239
- isApproved,
1240
1372
  hasUserContext: !!userContext,
1241
1373
  userContextKeys: userContext ? Object.keys(userContext) : [],
1242
- approvedValue: userContext?.approved,
1243
- fullUserContext: JSON.stringify(userContext, null, 2)
1374
+ hasApprovalBlock: !!(userContext && userContext.approval),
1375
+ hasToken: !!(userContext && userContext.approval && userContext.approval.token)
1244
1376
  });
1245
-
1246
- if (needsApproval && !isApproved) {
1377
+
1378
+ if (needsApproval) {
1247
1379
  logger.info(`[${tool.name}] Tool requires approval, generating approval request`);
1248
1380
  const approvalRequest = generateApprovalRequest(tool, args);
1249
-
1250
1381
  return {
1251
- content: [{
1252
- type: "text",
1253
- text: JSON.stringify(approvalRequest, null, 2)
1254
- }],
1382
+ content: [
1383
+ { type: "text", text: JSON.stringify(approvalRequest, null, 2) },
1384
+ {
1385
+ type: "text",
1386
+ text: `ACTION_NOT_EXECUTED — APPROVAL_REQUIRED\n\n` +
1387
+ `Tool '${tool.name}' was NOT executed. The host must render an approval dialog from the JSON payload above and re-invoke this tool with __userContext.approval = {approved: true, token: '<approvalToken from payload>'} to actually perform the action. The token is single-use and expires in ${Math.floor(APPROVAL_TOKEN_TTL_MS / 1000)}s. Do NOT fabricate the token. Do NOT report success to the user; surface the dialog instead.`
1388
+ }
1389
+ ],
1255
1390
  isApprovalRequired: true,
1256
1391
  approvalRequest: approvalRequest
1257
1392
  };
1258
1393
  }
1259
1394
 
1260
- if (needsApproval && isApproved) {
1261
- logger.info(`[${tool.name}] Tool was approved, executing actual API call`);
1262
- }
1263
-
1264
1395
  // Extract user context for token usage tracking
1265
1396
  const userId = userContext?.userId || 'unknown';
1266
1397
  const chatId = userContext?.chatId || null; // Use NULL for direct MCP calls
@@ -1311,6 +1442,8 @@ if (mcpConfig.tools && mcpConfig.tools.length > 0) {
1311
1442
  };
1312
1443
  }
1313
1444
 
1445
+ // (validateToolArgs already ran pre-approval; no duplicate check here.)
1446
+
1314
1447
  // Use adjusted args with enforced limits
1315
1448
  const adjustedArgs = limitsValidation.adjustedArgs;
1316
1449