@yawlabs/lemonsqueezy-mcp 0.10.13 → 0.11.0

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/CHANGELOG.md CHANGED
@@ -2,6 +2,88 @@
2
2
 
3
3
  All notable changes to `@yawlabs/lemonsqueezy-mcp` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versioning follows [SEMVER.md](./SEMVER.md).
4
4
 
5
+ ## [0.11.0] -- 2026-08-07
6
+
7
+ ### Security
8
+
9
+ - **`redactSecrets` no longer degrades to exponential traversal on a shared-reference payload.** The cycle guard tracks the ancestor path (entries removed on the way back up) so a merely *shared* object is redacted normally instead of being falsely reported as `[CIRCULAR]` -- but an ancestor set alone is O(paths), not O(nodes). A "diamond chain" holding two references to the same child at every level has 2^depth paths over depth+1 objects: measured at 3.2 seconds for 23 objects, doubling per level, which at the 32-level cap would block the stdio server for the better part of an hour from inside the audit path. A memo of completed subtrees restores linear behaviour. Reuse is gated on the cached entry having been computed with at least as much depth budget as the current position, so a subtree truncated near the cap is never replayed into a shallower slot where real (possibly secret-bearing) input belongs.
10
+
11
+ ### Added
12
+
13
+ - **`validation_error` log/audit status.** Failure entries previously collapsed client mistakes into `exception`, the same bucket as upstream 5xx and genuine faults. The new `ToolInputError` is tagged `validation_error`, so a log scan at `LEMONSQUEEZY_LOG=error` can separate "an agent sent a malformed request" from "the server or upstream faulted". `guardrail_block` (operator policy refused it) is unchanged.
14
+ - **`preflight` hook on the tool contract.** An input-dependent guardrail that runs ahead of the rate limiters. Both refund tools now declare the cap check there, so a rejected over-cap refund no longer consumes the caller's destructive and `money`-class budgets -- previously a client looping on an over-cap amount could exhaust a `money:2/h` allowance on calls that never left the process.
15
+ - **Cross-store disclosure on `ls_list_stores`.** It has no `storeId` field and no parent ID to scope by, so `LEMONSQUEEZY_ALLOWED_STORE_IDS` does not gate it and it enumerates every store the API key can see. This was documented in the README but absent from the tool description an agent actually reads. `ls_list_affiliates` carried the note already.
16
+
17
+ ### Fixed
18
+
19
+ - **Empty-PATCH guards on the remaining three update tools.** `ls_update_customer`, `ls_update_subscription`, and `ls_update_license_key` sent `attributes: {}` upstream when called with only an ID; `ls_update_webhook` had rejected this locally since 0.10.10. All four now reject before the round-trip and throw `ToolInputError`.
20
+ - **`ls_update_subscription` and `ls_update_customer` descriptions now disclose their conditional-destructive behaviour.** A tool whose destructiveness depends on the input carries `destructiveHint: false`, because the MCP annotation is static and the verdict is per-call -- so an MCP client will not prompt, and the description is the only signal left. `ls_update_subscription` said nothing about pausing or plan switches; `ls_update_customer` mentioned auditing only to point at `ls_archive_customer`, never saying that setting `status: 'archived'` *here* is itself rate-limited and audited. A new invariant in `tools.test.ts` fails the build if any predicate-carrying tool omits the warning.
21
+ - **`Retry-After: " "` no longer means "retry immediately".** `Number("")` is `0`, not `NaN`, so a whitespace-only header fell through the numeric branch and produced a 0 ms wait instead of the 1 s default.
22
+ - **`include: ""` no longer emits a bare `?include=`.** The include schemas set `.max()` but no `.min()`, so an empty string is valid input and split to `[""]`.
23
+ - **Sink response size limit measures UTF-8 bytes, not UTF-16 code units.** `String.length` undercounts every multi-byte character, so a non-ASCII body could exceed the 10 MB limit while reporting a char count under it -- and the message still said "bytes".
24
+ - **A bare `{"error": "..."}` envelope is now read on the management API**, not just the License API, via the shared error handler.
25
+ - **A throwing `isDestructive` predicate can no longer escape as an unhandled rejection.** It is evaluated outside the main try block so it stays in scope for the audit path on the error branch; it now has its own guard that fails closed (treats the call as destructive) and logs the fault.
26
+
27
+ ### Changed
28
+
29
+ - **`apiRequest` and `licenseRequest` share one error path and one 2xx body reader.** Roughly 90 lines of near-identical parse/log/decorate logic collapsed to two helpers. `licenseRequest` still deliberately does not bust the API-key cache on 401, since it authenticates with the caller's license key rather than `LEMONSQUEEZY_API_KEY`.
30
+ - **Both generate-invoice tools share one query builder** (`buildInvoiceQuery`) instead of duplicating an eight-field `URLSearchParams` block.
31
+ - **Cross-store notes are generated from each module's `requiredFilters` array**, so the disclosure and the runtime gate cannot drift apart.
32
+ - **`src/index.ts` is free of top-level await.** The version fallback used `await import("node:module")`, which the CJS single-binary build cannot emit; it survived only because both builds define `__VERSION__` and esbuild constant-folded the branch away. A static `createRequire` import removes the dependency on that folding -- verified by bundling to CJS with the define absent.
33
+
34
+ ### Added
35
+
36
+ - **Opt-in [oam.js](https://oamjs.org) build tooling.** `npm run check:oam` type-checks via `oam check` (tsgo, TypeScript 7 native) -- 2878ms against 4406ms for `tsc --noEmit`, same clean result. `npm run build:binary:oam` builds the standalone binary via `oam compile` instead of the Node SEA path, writing to the same `bin/<platform>-<arch>/` location so the release staging script consumes either unchanged. Neither touches the published npm package.
37
+
38
+ **Node remains the default runtime.** oam runs the server unmodified (verified against 0.8.2: full handshake, all 64 tools, the audit-log resource, working `fetch`, guardrail errors identical to Node), but cold start measured 196ms for Node against 424ms for `oam run` -- and since an MCP client pays startup once per session, that is the cost that matters. oam is also not distributed on npm, so defaulting to it would break `npx` for anyone without it. `src/` stays runtime-agnostic (no `oam:` imports, tests on `node:test`) so the Node path remains a real fallback rather than a nominal one.
39
+
40
+ - `oam/` added to `.gitignore` -- any `oam` invocation writes a bytecode cache there.
41
+
42
+ ### Documentation
43
+
44
+ - `0` documented as a valid value for the refund cap and both rate limits (blocks everything; unset/empty means no limit).
45
+ - `LEMONSQUEEZY_LOG` row now lists what each failure `status` means.
46
+ - README's allowlist row no longer describes `ls_list_stores` as routing "by its own resource ID"; the two genuinely ungated list tools are called out separately from the ID-targeted ones.
47
+
48
+ ## [0.10.13] -- 2026-06-11
49
+
50
+ ### Added
51
+
52
+ - **Cross-platform single-binary pipeline** (Scoop + Homebrew) via `scripts/build-binary.mjs` and `scripts/stage-release-asset.mjs`.
53
+
54
+ ### Fixed
55
+
56
+ - **`npm test` runs every test file under POSIX sh**, not only nested ones -- an unquoted `**/*` glob skipped the top-level files.
57
+
58
+ ### Changed
59
+
60
+ - Node engine requirement raised to `>=22`; dev-dependency vulnerabilities cleared.
61
+
62
+ ## [0.10.12] -- 2026-06-02
63
+
64
+ ### Fixed
65
+
66
+ - **Release drift guard compares tag-object SHAs**, so resuming a partial release no longer false-aborts.
67
+ - **`SKIP_LINT=1` escape hatch** for environments where the lint runner itself is broken.
68
+ - **`release.sh` refuses to push if origin's tag drifted from local.**
69
+ - README "Add to Yaw MCP" badge points at the https forwarder.
70
+
71
+ ### Added
72
+
73
+ - End-to-end coverage for the refund-cap guard on both refund tools.
74
+
75
+ ## [0.10.11] -- 2026-05-28
76
+
77
+ ### Changed
78
+
79
+ - **MCP Registry publish folded into `release.sh`**; `release.yml` and the remaining non-release workflows removed. `release.sh` is now the only release path.
80
+
81
+ ### Fixed
82
+
83
+ - Confirmation prompt is tty-gated, so non-interactive runs no longer block.
84
+ - `server.json` syncs unconditionally rather than only inside the version-bump branch.
85
+ - Falls back to the `gh` auth token for MCP Registry authentication.
86
+
5
87
  ## [0.10.10] -- 2026-05-22
6
88
 
7
89
  ### Fixed
@@ -390,6 +472,10 @@ Hardening pass for unattended automation against live billing flows.
390
472
  Initial release. 59 tools covering all 17 LemonSqueezy API resources.
391
473
 
392
474
  [Unreleased]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.9...HEAD
475
+ [0.11.0]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.13...v0.11.0
476
+ [0.10.13]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.12...v0.10.13
477
+ [0.10.12]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.11...v0.10.12
478
+ [0.10.11]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.10...v0.10.11
393
479
  [0.10.10]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.9...v0.10.10
394
480
  [0.10.9]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.8...v0.10.9
395
481
  [0.10.8]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.7...v0.10.8
package/README.md CHANGED
@@ -229,12 +229,12 @@ All configuration is via environment variables. Only `LEMONSQUEEZY_API_KEY` (or
229
229
  | `LEMONSQUEEZY_API_KEY` | LemonSqueezy API token. |
230
230
  | `LEMONSQUEEZY_API_KEY_COMMAND` | Command whose stdout produces the API key. Overrides `LEMONSQUEEZY_API_KEY`. Output is cached for 1 hour. Use this to pull short-lived credentials from a vault (`op read`, `gcloud secrets versions access`, etc.) without writing them to env vars. The cache is keyed by the command string, so changing it mid-process refreshes on the next request; it is also invalidated automatically on a 401/403 from the API, so a key rotated upstream takes effect on the next call without waiting for the TTL. |
231
231
  | `LEMONSQUEEZY_TEST_API_KEY` | Optional test-mode key. When set and non-empty, it takes precedence over `LEMONSQUEEZY_API_KEY` (but not over `LEMONSQUEEZY_API_KEY_COMMAND`). On first activation per process, the server prints a one-line JSON `test_mode` notice to stderr so you can confirm test mode is engaged. Use this to point the server at a sandbox/test store without unsetting your production key. |
232
- | `LEMONSQUEEZY_ALLOWED_STORE_IDS` | Comma-separated allowlist of store IDs. When set: (1) any tool whose input includes a `storeId` rejects calls to a non-allowed store; (2) tools that *accept* a `storeId` filter (e.g. `ls_list_orders`, `ls_list_subscriptions`) require it — calls without one are blocked so a missing filter cannot return data from every store the API key can see. Tools with no `storeId` field at all (e.g. `ls_refund_order`, `ls_cancel_subscription`, `ls_archive_customer`, `ls_delete_webhook`, `ls_delete_discount`, `ls_update_license_key`, `ls_list_stores`) route by their own resource ID and are **not** gated by this allowlist. LemonSqueezy API keys are issued at the account level and authorize access to every store in that account, so this allowlist is the only in-process store boundary the server can enforce. Pair it with `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` / `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` / `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` for defense in depth, and — if your account hosts multiple stores you don't want exposed to the same agent — keep those stores under a separate LemonSqueezy account whose API key isn't reachable from this server. |
233
- | `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` | Rejects `ls_refund_order` and `ls_refund_subscription_invoice` calls above this amount. |
234
- | `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Positive integer. Max destructive tool calls per 60-second rolling window. In-process limit — per MCP server instance, not global; each `npx` cold start resets the window. Counts include every refund, cancellation, archive, and delete tool, plus the input-dependent destructive paths: `ls_update_license_key` calls that set `disabled: true` *or* change `activationLimit`, `ls_update_subscription` calls that pause or switch plan, and `ls_update_customer` calls with `status: "archived"`. |
232
+ | `LEMONSQUEEZY_ALLOWED_STORE_IDS` | Comma-separated allowlist of store IDs. When set: (1) any tool whose input includes a `storeId` rejects calls to a non-allowed store; (2) tools that *accept* a `storeId` filter (e.g. `ls_list_orders`, `ls_list_subscriptions`) require it — calls without one are blocked so a missing filter cannot return data from every store the API key can see. Tools with no `storeId` field at all are **not** gated by this allowlist, in two distinct shapes: (a) ID-targeted tools (`ls_refund_order`, `ls_cancel_subscription`, `ls_archive_customer`, `ls_delete_webhook`, `ls_delete_discount`, `ls_update_license_key`) route by their own resource ID, so the caller must already know the ID; (b) `ls_list_stores` and `ls_list_affiliates` take no scoping ID at all and **return rows from every store the API key can see** `ls_list_stores` will enumerate stores outside the allowlist. Both say so in their own tool descriptions. Other list-by-parent tools (`ls_list_prices`, `ls_list_files`, `ls_list_variants`, `ls_list_order_items`, `ls_list_discount_redemptions`, `ls_list_license_key_instances`, `ls_list_subscription_items`, `ls_list_usage_records`) require a parent-ID filter when the allowlist is set — a partial mitigation, since that parent can itself belong to a non-allowed store. LemonSqueezy API keys are issued at the account level and authorize access to every store in that account, so this allowlist is the only in-process store boundary the server can enforce. Pair it with `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` / `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` / `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` for defense in depth, and — if your account hosts multiple stores you don't want exposed to the same agent — keep those stores under a separate LemonSqueezy account whose API key isn't reachable from this server. |
233
+ | `LEMONSQUEEZY_MAX_REFUND_AMOUNT_CENTS` | Non-negative integer. Rejects `ls_refund_order` and `ls_refund_subscription_invoice` calls above this amount. Unset or empty means no cap; **`0` is a valid value and blocks every refund** (the schemas require `amount >= 1`), so use it as a kill switch. The check runs *before* the rate limiters, so a rejected over-cap refund does not consume your destructive or `money`-class budget. |
234
+ | `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` | Non-negative integer. Max destructive tool calls per 60-second rolling window. Unset or empty means no limit; **`0` blocks every destructive call.** In-process limit — per MCP server instance, not global; each `npx` cold start resets the window. Counts include every refund, cancellation, archive, and delete tool, plus the input-dependent destructive paths: `ls_update_license_key` calls that set `disabled: true` *or* change `activationLimit`, `ls_update_subscription` calls that pause or switch plan, and `ls_update_customer` calls with `status: "archived"`. |
235
235
  | `LEMONSQUEEZY_DISABLE_CLASSES` | Comma-separated list of [authority classes](#authority-classes) to refuse outright. Any tool whose class is listed returns a `guardrail_block` before the API call is attempted. Example: `LEMONSQUEEZY_DISABLE_CLASSES=money,recurring,pii` lets an agent run reads but blocks refunds, subscription changes, and customer-record access. Unknown class names throw at server startup. |
236
- | `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` | Per-class rolling rate limits, comma-separated. Each entry is `class:N`, `class:N/m`, or `class:N/h` (bare numbers default to per-minute). Example: `money:2/h,recurring:5/h,key:10/m`. Composes with `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` — both must pass. In-process per server instance. |
237
- | `LEMONSQUEEZY_LOG` | Structured-log verbosity to stderr. Set to `all` (or legacy `json`) to log every tool and HTTP call, `audit` to log only destructive-call audit entries plus errors (recommended for production), `error` to log only failures. Unset: no logs. Destructive calls are tagged `audit: true` and include their inputs. |
236
+ | `LEMONSQUEEZY_RATE_LIMIT_PER_CLASS` | Per-class rolling rate limits, comma-separated. Each entry is `class:N`, `class:N/m`, or `class:N/h` (bare numbers default to per-minute). Example: `money:2/h,recurring:5/h,key:10/m`. Composes with `LEMONSQUEEZY_DESTRUCTIVE_RATE_LIMIT` — both must pass. A limit of `0` blocks every call in that class; classes you don't list are unlimited. Malformed entries throw at server startup. In-process per server instance. |
237
+ | `LEMONSQUEEZY_LOG` | Structured-log verbosity to stderr. Set to `all` (or legacy `json`) to log every tool and HTTP call, `audit` to log only destructive-call audit entries plus errors (recommended for production), `error` to log only failures. Unset: no logs. Destructive calls are tagged `audit: true` and include their inputs. Failure entries carry a `status` that identifies the cause: `guardrail_block` (operator policy refused the call), `validation_error` (the client sent a malformed request, e.g. an update with no fields to change), `exception` (something faulted), plus `timeout` / `network_error` and raw HTTP status codes. |
238
238
  | `LEMONSQUEEZY_SINK_URL` | Base URL of an optional [@yawlabs/lemonsqueezy-webhook-sink](https://github.com/YawLabs/lemonsqueezy-webhook-sink) instance (e.g. `https://webhooks.example.com`). Trailing slashes are stripped. Enables the `ls_sink_*` reconciliation tools below. Unset: the tools are still registered but return a "not configured" error when called. |
239
239
  | `LEMONSQUEEZY_SINK_ADMIN_TOKEN` | Bearer token for the sink's admin endpoints. Must match the sink's `WEBHOOK_SINK_ADMIN_TOKEN`. Required when `LEMONSQUEEZY_SINK_URL` is set; if the sink itself was started without an admin token, its admin endpoints return 404 and `ls_sink_*` calls surface that diagnostically. |
240
240
 
@@ -316,6 +316,33 @@ npm run gen:containerfile # regenerate Containerfile
316
316
  npm run check:containerfile # CI runs this; non-zero exit means the two have drifted
317
317
  ```
318
318
 
319
+ ## Running on oam.js (optional)
320
+
321
+ [oam.js](https://oamjs.org) runs this server unmodified. Verified against oam 0.8.2: full MCP handshake, all 64 tools, the `lemonsqueezy://audit-log` resource, working `fetch`, and guardrail rejections with error text identical to Node.
322
+
323
+ ```jsonc
324
+ {
325
+ "mcpServers": {
326
+ "lemonsqueezy": {
327
+ "command": "oam",
328
+ "args": ["run", "/path/to/lemonsqueezy-mcp/dist/index.js"],
329
+ "env": { "LEMONSQUEEZY_API_KEY": "..." }
330
+ }
331
+ }
332
+ }
333
+ ```
334
+
335
+ Note the `--` separator if you pass arguments to the server rather than to oam: `oam run dist/index.js -- version`.
336
+
337
+ **Node stays the default, deliberately.** An MCP client cold-starts this server once per session, so startup is the cost that actually gets paid — and on the machine this was measured on, Node won: **196ms median against 424ms for `oam run`** (8 runs each, `version` subcommand, which exercises full boot plus tool registration). Making oam the default would mean either a launcher that probes for it on every start — a cost paid by everyone, including the majority who do not have oam installed — or making oam a hard requirement, which would break `npx @yawlabs/lemonsqueezy-mcp` for every user without it, since oam is not distributed on npm. Neither is worth it to reach a runtime that is slower here. Measure on your own hardware before concluding anything; if oam wins on yours, the config above is all you need.
338
+
339
+ Two places oam *does* win for this repo, both opt-in and neither touching the npm package:
340
+
341
+ - **`npm run check:oam`** — type-checks via `oam check` (tsgo, TypeScript 7 native). Measured 2878ms against 4406ms for `tsc --noEmit`, same clean result. `npx tsc --noEmit` remains the portable default and is what the pre-commit checklist calls for.
342
+ - **`npm run build:binary:oam`** — builds the standalone binary via `oam compile` instead of the Node SEA path. Measured 57.14 MB. Writes to the same `bin/<platform>-<arch>/` path as `npm run build:binary`, so the release staging script consumes either unchanged — run one or the other, not both. If you redistribute that binary it embeds oam's runtime, so ship oam's `LICENSE`, `NOTICE` and `THIRD_PARTY_LICENSES.md` with it.
343
+
344
+ The source stays runtime-agnostic on purpose: no `oam:` imports anywhere, and tests stay on `node:test`. That is what keeps the Node fallback real rather than nominal — an `oam:test` or `oam:`-prefixed import would make "falls back to Node" false the moment it landed. Any `oam` invocation writes a bytecode cache to `oam/` in the working directory; that path is gitignored.
345
+
319
346
  ## Releasing
320
347
 
321
348
  Two paths from a clean checkout of `main`. Both produce the same artifact (npm publish with provenance + GitHub release).
package/dist/index.js CHANGED
@@ -6889,6 +6889,9 @@ var require_dist = __commonJS({
6889
6889
  }
6890
6890
  });
6891
6891
 
6892
+ // src/index.ts
6893
+ import "node:module";
6894
+
6892
6895
  // node_modules/zod/v3/external.js
6893
6896
  var external_exports = {};
6894
6897
  __export(external_exports, {
@@ -21108,6 +21111,12 @@ var GuardrailError = class extends Error {
21108
21111
  this.name = "GuardrailError";
21109
21112
  }
21110
21113
  };
21114
+ var ToolInputError = class extends Error {
21115
+ constructor(message) {
21116
+ super(message);
21117
+ this.name = "ToolInputError";
21118
+ }
21119
+ };
21111
21120
  var AUTHORITY_CLASSES = ["read", "pii", "mutate", "money", "recurring", "key", "webhook"];
21112
21121
  function isAuthorityClass(s) {
21113
21122
  return AUTHORITY_CLASSES.includes(s);
@@ -21292,7 +21301,14 @@ function getLogLevel() {
21292
21301
  if (v === "error") return "error";
21293
21302
  return "off";
21294
21303
  }
21295
- var ERROR_STATUS_TAGS = /* @__PURE__ */ new Set(["error", "exception", "guardrail_block", "timeout", "network_error"]);
21304
+ var ERROR_STATUS_TAGS = /* @__PURE__ */ new Set([
21305
+ "error",
21306
+ "exception",
21307
+ "guardrail_block",
21308
+ "validation_error",
21309
+ "timeout",
21310
+ "network_error"
21311
+ ]);
21296
21312
  function isErrorEntry(entry) {
21297
21313
  if (entry.error !== void 0) return true;
21298
21314
  if (typeof entry.status === "string") return ERROR_STATUS_TAGS.has(entry.status);
@@ -21374,6 +21390,7 @@ var JITTER_FRACTION = 0.25;
21374
21390
  function parseRetryAfterMs(header) {
21375
21391
  if (!header) return DEFAULT_RETRY_WAIT_MS;
21376
21392
  const trimmed = header.trim();
21393
+ if (trimmed === "") return DEFAULT_RETRY_WAIT_MS;
21377
21394
  const seconds = Number(trimmed);
21378
21395
  if (!Number.isNaN(seconds)) {
21379
21396
  return seconds >= 0 ? seconds * 1e3 : DEFAULT_RETRY_WAIT_MS;
@@ -21586,7 +21603,10 @@ function buildQuery(params) {
21586
21603
  if (!params) return "";
21587
21604
  const parts = [];
21588
21605
  if (params.include?.length) {
21589
- parts.push(`include=${encodeURIComponent(params.include.map((s) => s.trim()).join(","))}`);
21606
+ const segments = params.include.map((s) => s.trim()).filter(Boolean);
21607
+ if (segments.length > 0) {
21608
+ parts.push(`include=${encodeURIComponent(segments.join(","))}`);
21609
+ }
21590
21610
  }
21591
21611
  if (params.filter) {
21592
21612
  for (const [key, value] of Object.entries(params.filter)) {
@@ -21606,6 +21626,49 @@ function buildQuery(params) {
21606
21626
  function decorateError(error2, requestId) {
21607
21627
  return requestId ? `${error2} (request_id: ${requestId})` : error2;
21608
21628
  }
21629
+ async function handleErrorResponse(res, route, latency_ms, requestId) {
21630
+ const errorBody = await res.text();
21631
+ try {
21632
+ const parsed = JSON.parse(errorBody);
21633
+ const detail = parsed.errors?.[0]?.detail ?? parsed.error ?? errorBody;
21634
+ logEvent({
21635
+ event: "http_call",
21636
+ method: route.method,
21637
+ path: route.path,
21638
+ status: res.status,
21639
+ latency_ms,
21640
+ request_id: requestId,
21641
+ error: detail
21642
+ });
21643
+ return {
21644
+ ok: false,
21645
+ status: res.status,
21646
+ data: parsed,
21647
+ error: decorateError(detail, requestId),
21648
+ requestId
21649
+ };
21650
+ } catch {
21651
+ logEvent({
21652
+ event: "http_call",
21653
+ method: route.method,
21654
+ path: route.path,
21655
+ status: res.status,
21656
+ latency_ms,
21657
+ request_id: requestId,
21658
+ error: errorBody
21659
+ });
21660
+ return {
21661
+ ok: false,
21662
+ status: res.status,
21663
+ error: decorateError(errorBody, requestId),
21664
+ requestId
21665
+ };
21666
+ }
21667
+ }
21668
+ async function readJsonBody(res) {
21669
+ const bodyText = await res.text();
21670
+ return bodyText.trim() ? JSON.parse(bodyText) : void 0;
21671
+ }
21609
21672
  function formatTimeoutMessage(err, fallbackElapsedMs) {
21610
21673
  const plural = (n) => `${n} attempt${n === 1 ? "" : "s"}`;
21611
21674
  if (isRetryTimeoutError(err)) {
@@ -21649,43 +21712,7 @@ async function apiRequest(method, path, body) {
21649
21712
  if (res.status === 401 || res.status === 403) {
21650
21713
  invalidateApiKeyCache();
21651
21714
  }
21652
- const errorBody = await res.text();
21653
- try {
21654
- const parsed = JSON.parse(errorBody);
21655
- const detail = parsed.errors?.[0]?.detail ?? errorBody;
21656
- logEvent({
21657
- event: "http_call",
21658
- method,
21659
- path,
21660
- status: res.status,
21661
- latency_ms,
21662
- request_id: requestId,
21663
- error: detail
21664
- });
21665
- return {
21666
- ok: false,
21667
- status: res.status,
21668
- data: parsed,
21669
- error: decorateError(detail, requestId),
21670
- requestId
21671
- };
21672
- } catch {
21673
- logEvent({
21674
- event: "http_call",
21675
- method,
21676
- path,
21677
- status: res.status,
21678
- latency_ms,
21679
- request_id: requestId,
21680
- error: errorBody
21681
- });
21682
- return {
21683
- ok: false,
21684
- status: res.status,
21685
- error: decorateError(errorBody, requestId),
21686
- requestId
21687
- };
21688
- }
21715
+ return handleErrorResponse(res, { method, path }, latency_ms, requestId);
21689
21716
  }
21690
21717
  logEvent({
21691
21718
  event: "http_call",
@@ -21698,9 +21725,7 @@ async function apiRequest(method, path, body) {
21698
21725
  if (res.status === 204) {
21699
21726
  return { ok: true, status: res.status, requestId };
21700
21727
  }
21701
- const bodyText = await res.text();
21702
- const data = bodyText.trim() ? JSON.parse(bodyText) : void 0;
21703
- return { ok: true, status: res.status, data, requestId };
21728
+ return { ok: true, status: res.status, data: await readJsonBody(res), requestId };
21704
21729
  }
21705
21730
  async function licenseRequest(path, body) {
21706
21731
  const url = `${BASE_URL}${path}`;
@@ -21740,44 +21765,7 @@ async function licenseRequest(path, body) {
21740
21765
  const latency_ms = Date.now() - start;
21741
21766
  const requestId = res.headers.get("x-request-id") ?? void 0;
21742
21767
  if (!res.ok) {
21743
- const errorBody = await res.text();
21744
- try {
21745
- const parsed = JSON.parse(errorBody);
21746
- const p = parsed;
21747
- const detail = p.errors?.[0]?.detail ?? p.error ?? errorBody;
21748
- logEvent({
21749
- event: "http_call",
21750
- method: "POST",
21751
- path,
21752
- status: res.status,
21753
- latency_ms,
21754
- request_id: requestId,
21755
- error: detail
21756
- });
21757
- return {
21758
- ok: false,
21759
- status: res.status,
21760
- data: parsed,
21761
- error: decorateError(detail, requestId),
21762
- requestId
21763
- };
21764
- } catch {
21765
- logEvent({
21766
- event: "http_call",
21767
- method: "POST",
21768
- path,
21769
- status: res.status,
21770
- latency_ms,
21771
- request_id: requestId,
21772
- error: errorBody
21773
- });
21774
- return {
21775
- ok: false,
21776
- status: res.status,
21777
- error: decorateError(errorBody, requestId),
21778
- requestId
21779
- };
21780
- }
21768
+ return handleErrorResponse(res, { method: "POST", path }, latency_ms, requestId);
21781
21769
  }
21782
21770
  logEvent({
21783
21771
  event: "http_call",
@@ -21787,9 +21775,7 @@ async function licenseRequest(path, body) {
21787
21775
  latency_ms,
21788
21776
  request_id: requestId
21789
21777
  });
21790
- const bodyText = await res.text();
21791
- const data = bodyText.trim() ? JSON.parse(bodyText) : void 0;
21792
- return { ok: true, status: res.status, data, requestId };
21778
+ return { ok: true, status: res.status, data: await readJsonBody(res), requestId };
21793
21779
  }
21794
21780
  function getHandler(endpoint, idField) {
21795
21781
  return async (input) => {
@@ -21813,6 +21799,32 @@ function listHandler(endpoint, filterMap = {}) {
21813
21799
  };
21814
21800
  return Object.assign(handler, { filterMap });
21815
21801
  }
21802
+ var INVOICE_FIELD_MAP = {
21803
+ name: "name",
21804
+ address: "address",
21805
+ city: "city",
21806
+ state: "state",
21807
+ zipCode: "zip_code",
21808
+ country: "country",
21809
+ notes: "notes",
21810
+ locale: "locale"
21811
+ };
21812
+ function buildInvoiceQuery(input) {
21813
+ const params = new URLSearchParams();
21814
+ for (const [inputKey, apiKey] of Object.entries(INVOICE_FIELD_MAP)) {
21815
+ const value = input[inputKey];
21816
+ if (value !== void 0) params.set(apiKey, value);
21817
+ }
21818
+ const qs = params.toString();
21819
+ return qs ? `?${qs}` : "";
21820
+ }
21821
+ var CROSS_STORE_TRAILER = "Pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.";
21822
+ function crossStoreFilterNote(filters) {
21823
+ return `Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: ${filters.join(", ")}. Even with that set, ${CROSS_STORE_TRAILER}`;
21824
+ }
21825
+ function crossStoreUngatedNote(returns) {
21826
+ return `Cross-store note: LEMONSQUEEZY_ALLOWED_STORE_IDS does NOT gate this tool -- it has no storeId field and no parent ID filter to scope by, so it returns ${returns}. ${CROSS_STORE_TRAILER}`;
21827
+ }
21816
21828
  async function apiGet(path) {
21817
21829
  return apiRequest("GET", path);
21818
21830
  }
@@ -21848,7 +21860,9 @@ var affiliateTools = [
21848
21860
  {
21849
21861
  name: "ls_list_affiliates",
21850
21862
  authorityClass: "read",
21851
- description: "List all affiliates for the authenticated user's stores, optionally filtered by user email. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool can still return affiliates tied to non-allowed stores -- the endpoint has no parent ID filter to scope by. Pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
21863
+ description: `List all affiliates for the authenticated user's stores, optionally filtered by user email. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreUngatedNote(
21864
+ "affiliates tied to every store the API key can see, including non-allowed ones"
21865
+ )}`,
21852
21866
  annotations: {
21853
21867
  title: "List affiliates",
21854
21868
  readOnlyHint: true,
@@ -22044,7 +22058,7 @@ var customerTools = [
22044
22058
  {
22045
22059
  name: "ls_update_customer",
22046
22060
  authorityClass: "pii",
22047
- description: "Update an existing customer's name, email, city, region, country, or status. The only supported status value is 'archived' \u2014 use ls_archive_customer for the dedicated, audit-tagged path.",
22061
+ description: "Update an existing customer's name, email, city, region, country, or status. The only supported status value is 'archived'; setting it here is the same operation as ls_archive_customer and is treated as destructive (rate-limited and audited). Other field edits are not.",
22048
22062
  annotations: {
22049
22063
  title: "Update customer",
22050
22064
  readOnlyHint: false,
@@ -22078,6 +22092,11 @@ var customerTools = [
22078
22092
  if (input.region !== void 0) attributes.region = input.region;
22079
22093
  if (input.country !== void 0) attributes.country = input.country;
22080
22094
  if (input.status !== void 0) attributes.status = input.status;
22095
+ if (Object.keys(attributes).length === 0) {
22096
+ throw new ToolInputError(
22097
+ "ls_update_customer requires at least one of: name, email, city, region, country, status"
22098
+ );
22099
+ }
22081
22100
  return apiPatch(`/customers/${encodePath(input.customerId)}`, {
22082
22101
  data: {
22083
22102
  type: "customers",
@@ -22114,6 +22133,7 @@ var customerTools = [
22114
22133
  ];
22115
22134
 
22116
22135
  // src/tools/discount-redemptions.ts
22136
+ var LIST_DISCOUNT_REDEMPTIONS_FILTERS = ["discountId", "orderId"];
22117
22137
  var discountRedemptionTools = [
22118
22138
  {
22119
22139
  name: "ls_get_discount_redemption",
@@ -22135,7 +22155,7 @@ var discountRedemptionTools = [
22135
22155
  {
22136
22156
  name: "ls_list_discount_redemptions",
22137
22157
  authorityClass: "read",
22138
- description: "List all discount redemptions, optionally filtered by discount or order. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: discountId, orderId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22158
+ description: `List all discount redemptions, optionally filtered by discount or order. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_DISCOUNT_REDEMPTIONS_FILTERS)}`,
22139
22159
  annotations: {
22140
22160
  title: "List discount redemptions",
22141
22161
  readOnlyHint: true,
@@ -22150,7 +22170,7 @@ var discountRedemptionTools = [
22150
22170
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22151
22171
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22152
22172
  }),
22153
- requiredFilters: ["discountId", "orderId"],
22173
+ requiredFilters: LIST_DISCOUNT_REDEMPTIONS_FILTERS,
22154
22174
  handler: listHandler("/discount-redemptions", { discountId: "discount_id", orderId: "order_id" })
22155
22175
  }
22156
22176
  ];
@@ -22273,6 +22293,7 @@ var discountTools = [
22273
22293
  ];
22274
22294
 
22275
22295
  // src/tools/files.ts
22296
+ var LIST_FILES_FILTERS = ["variantId"];
22276
22297
  var fileTools = [
22277
22298
  {
22278
22299
  name: "ls_get_file",
@@ -22294,7 +22315,7 @@ var fileTools = [
22294
22315
  {
22295
22316
  name: "ls_list_files",
22296
22317
  authorityClass: "read",
22297
- description: "List all files, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: variantId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22318
+ description: `List all files, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_FILES_FILTERS)}`,
22298
22319
  annotations: {
22299
22320
  title: "List files",
22300
22321
  readOnlyHint: true,
@@ -22308,12 +22329,13 @@ var fileTools = [
22308
22329
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22309
22330
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22310
22331
  }),
22311
- requiredFilters: ["variantId"],
22332
+ requiredFilters: LIST_FILES_FILTERS,
22312
22333
  handler: listHandler("/files", { variantId: "variant_id" })
22313
22334
  }
22314
22335
  ];
22315
22336
 
22316
22337
  // src/tools/license-key-instances.ts
22338
+ var LIST_LICENSE_KEY_INSTANCES_FILTERS = ["licenseKeyId"];
22317
22339
  var licenseKeyInstanceTools = [
22318
22340
  {
22319
22341
  name: "ls_get_license_key_instance",
@@ -22335,7 +22357,7 @@ var licenseKeyInstanceTools = [
22335
22357
  {
22336
22358
  name: "ls_list_license_key_instances",
22337
22359
  authorityClass: "read",
22338
- description: "List all license key instances (activations), optionally filtered by license key. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: licenseKeyId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22360
+ description: `List all license key instances (activations), optionally filtered by license key. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_LICENSE_KEY_INSTANCES_FILTERS)}`,
22339
22361
  annotations: {
22340
22362
  title: "List license key instances",
22341
22363
  readOnlyHint: true,
@@ -22349,7 +22371,7 @@ var licenseKeyInstanceTools = [
22349
22371
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22350
22372
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22351
22373
  }),
22352
- requiredFilters: ["licenseKeyId"],
22374
+ requiredFilters: LIST_LICENSE_KEY_INSTANCES_FILTERS,
22353
22375
  handler: listHandler("/license-key-instances", { licenseKeyId: "license_key_id" })
22354
22376
  }
22355
22377
  ];
@@ -22434,6 +22456,11 @@ var licenseKeyTools = [
22434
22456
  if (input.activationLimit !== void 0) attributes.activation_limit = input.activationLimit;
22435
22457
  if (input.disabled !== void 0) attributes.disabled = input.disabled;
22436
22458
  if (input.expiresAt !== void 0) attributes.expires_at = input.expiresAt;
22459
+ if (Object.keys(attributes).length === 0) {
22460
+ throw new ToolInputError(
22461
+ "ls_update_license_key requires at least one of: activationLimit, disabled, expiresAt"
22462
+ );
22463
+ }
22437
22464
  return apiPatch(`/license-keys/${encodePath(input.licenseKeyId)}`, {
22438
22465
  data: {
22439
22466
  type: "license-keys",
@@ -22515,6 +22542,7 @@ var licenseTools = [
22515
22542
  ];
22516
22543
 
22517
22544
  // src/tools/order-items.ts
22545
+ var LIST_ORDER_ITEMS_FILTERS = ["orderId", "productId", "variantId"];
22518
22546
  var orderItemTools = [
22519
22547
  {
22520
22548
  name: "ls_get_order_item",
@@ -22536,7 +22564,7 @@ var orderItemTools = [
22536
22564
  {
22537
22565
  name: "ls_list_order_items",
22538
22566
  authorityClass: "read",
22539
- description: "List all order items, optionally filtered by order or product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: orderId, productId, variantId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22567
+ description: `List all order items, optionally filtered by order or product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_ORDER_ITEMS_FILTERS)}`,
22540
22568
  annotations: {
22541
22569
  title: "List order items",
22542
22570
  readOnlyHint: true,
@@ -22552,7 +22580,7 @@ var orderItemTools = [
22552
22580
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22553
22581
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22554
22582
  }),
22555
- requiredFilters: ["orderId", "productId", "variantId"],
22583
+ requiredFilters: LIST_ORDER_ITEMS_FILTERS,
22556
22584
  handler: listHandler("/order-items", { orderId: "order_id", productId: "product_id", variantId: "variant_id" })
22557
22585
  }
22558
22586
  ];
@@ -22623,17 +22651,7 @@ var orderTools = [
22623
22651
  locale: external_exports.string().max(1e4).optional().describe("Invoice language locale (e.g. 'en', 'fr', 'de')")
22624
22652
  }),
22625
22653
  handler: async (input) => {
22626
- const params = new URLSearchParams();
22627
- if (input.name !== void 0) params.set("name", input.name);
22628
- if (input.address !== void 0) params.set("address", input.address);
22629
- if (input.city !== void 0) params.set("city", input.city);
22630
- if (input.state !== void 0) params.set("state", input.state);
22631
- if (input.zipCode !== void 0) params.set("zip_code", input.zipCode);
22632
- if (input.country !== void 0) params.set("country", input.country);
22633
- if (input.notes !== void 0) params.set("notes", input.notes);
22634
- if (input.locale !== void 0) params.set("locale", input.locale);
22635
- const qs = params.toString();
22636
- return apiPost(`/orders/${encodePath(input.orderId)}/generate-invoice${qs ? `?${qs}` : ""}`);
22654
+ return apiPost(`/orders/${encodePath(input.orderId)}/generate-invoice${buildInvoiceQuery(input)}`);
22637
22655
  }
22638
22656
  },
22639
22657
  {
@@ -22651,6 +22669,12 @@ var orderTools = [
22651
22669
  orderId: lsIdSchema.describe("The order ID to refund"),
22652
22670
  amount: external_exports.number().int().min(1).describe("Refund amount in cents (e.g. 1000 = $10.00)")
22653
22671
  }),
22672
+ // Run the cap check ahead of the rate limiters (see `preflight` on
22673
+ // RegisterableTool) so a rejected over-cap refund does not consume the
22674
+ // caller's money-class and destructive budgets. The handler repeats the
22675
+ // check for direct callers that bypass the wrapper; checkRefundAmount is
22676
+ // pure, so running it twice is free.
22677
+ preflight: (input) => checkRefundAmount(input.amount),
22654
22678
  handler: async (input) => {
22655
22679
  checkRefundAmount(input.amount);
22656
22680
  return apiPost(`/orders/${encodePath(input.orderId)}/refund`, {
@@ -22661,6 +22685,7 @@ var orderTools = [
22661
22685
  ];
22662
22686
 
22663
22687
  // src/tools/prices.ts
22688
+ var LIST_PRICES_FILTERS = ["variantId"];
22664
22689
  var priceTools = [
22665
22690
  {
22666
22691
  name: "ls_get_price",
@@ -22682,7 +22707,7 @@ var priceTools = [
22682
22707
  {
22683
22708
  name: "ls_list_prices",
22684
22709
  authorityClass: "read",
22685
- description: "List all prices, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: variantId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
22710
+ description: `List all prices, optionally filtered by variant. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_PRICES_FILTERS)}`,
22686
22711
  annotations: {
22687
22712
  title: "List prices",
22688
22713
  readOnlyHint: true,
@@ -22696,7 +22721,7 @@ var priceTools = [
22696
22721
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
22697
22722
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
22698
22723
  }),
22699
- requiredFilters: ["variantId"],
22724
+ requiredFilters: LIST_PRICES_FILTERS,
22700
22725
  handler: listHandler("/prices", { variantId: "variant_id" })
22701
22726
  }
22702
22727
  ];
@@ -22852,10 +22877,11 @@ async function sinkRequest(config2, method, pathAndQuery) {
22852
22877
  }
22853
22878
  const text = readResult.text;
22854
22879
  if (!text.trim()) return { ok: true, data: {} };
22855
- if (text.length > MAX_BODY_SIZE_BYTES) {
22880
+ const byteLength = Buffer.byteLength(text, "utf8");
22881
+ if (byteLength > MAX_BODY_SIZE_BYTES) {
22856
22882
  return {
22857
22883
  ok: false,
22858
- error: `Sink response body too large: ${text.length} bytes exceeds ${MAX_BODY_SIZE_BYTES} byte limit`
22884
+ error: `Sink response body too large: ${byteLength} bytes exceeds ${MAX_BODY_SIZE_BYTES} byte limit`
22859
22885
  };
22860
22886
  }
22861
22887
  try {
@@ -22959,7 +22985,9 @@ var storeTools = [
22959
22985
  {
22960
22986
  name: "ls_list_stores",
22961
22987
  authorityClass: "read",
22962
- description: "List all stores for the authenticated user. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total.",
22988
+ description: `List all stores for the authenticated user. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreUngatedNote(
22989
+ "every store the API key can see, including stores outside the allowlist"
22990
+ )}`,
22963
22991
  annotations: {
22964
22992
  title: "List stores",
22965
22993
  readOnlyHint: true,
@@ -23047,18 +23075,8 @@ var subscriptionInvoiceTools = [
23047
23075
  locale: external_exports.string().max(1e4).optional().describe("Invoice language locale (e.g. 'en', 'fr', 'de')")
23048
23076
  }),
23049
23077
  handler: async (input) => {
23050
- const params = new URLSearchParams();
23051
- if (input.name !== void 0) params.set("name", input.name);
23052
- if (input.address !== void 0) params.set("address", input.address);
23053
- if (input.city !== void 0) params.set("city", input.city);
23054
- if (input.state !== void 0) params.set("state", input.state);
23055
- if (input.zipCode !== void 0) params.set("zip_code", input.zipCode);
23056
- if (input.country !== void 0) params.set("country", input.country);
23057
- if (input.notes !== void 0) params.set("notes", input.notes);
23058
- if (input.locale !== void 0) params.set("locale", input.locale);
23059
- const qs = params.toString();
23060
23078
  return apiPost(
23061
- `/subscription-invoices/${encodePath(input.subscriptionInvoiceId)}/generate-invoice${qs ? `?${qs}` : ""}`
23079
+ `/subscription-invoices/${encodePath(input.subscriptionInvoiceId)}/generate-invoice${buildInvoiceQuery(input)}`
23062
23080
  );
23063
23081
  }
23064
23082
  },
@@ -23077,6 +23095,9 @@ var subscriptionInvoiceTools = [
23077
23095
  subscriptionInvoiceId: lsIdSchema.describe("The subscription invoice ID to refund"),
23078
23096
  amount: external_exports.number().int().min(1).describe("Refund amount in cents (e.g. 1000 = $10.00)")
23079
23097
  }),
23098
+ // See the matching comment on ls_refund_order: the cap check runs ahead of
23099
+ // the rate limiters so a rejection costs the caller no budget.
23100
+ preflight: (input) => checkRefundAmount(input.amount),
23080
23101
  handler: async (input) => {
23081
23102
  checkRefundAmount(input.amount);
23082
23103
  return apiPost(`/subscription-invoices/${encodePath(input.subscriptionInvoiceId)}/refund`, {
@@ -23091,6 +23112,7 @@ var subscriptionInvoiceTools = [
23091
23112
  ];
23092
23113
 
23093
23114
  // src/tools/subscription-items.ts
23115
+ var LIST_SUBSCRIPTION_ITEMS_FILTERS = ["subscriptionId", "priceId"];
23094
23116
  var subscriptionItemTools = [
23095
23117
  {
23096
23118
  name: "ls_get_subscription_item",
@@ -23112,7 +23134,7 @@ var subscriptionItemTools = [
23112
23134
  {
23113
23135
  name: "ls_list_subscription_items",
23114
23136
  authorityClass: "read",
23115
- description: "List all subscription items, optionally filtered by subscription or price. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: subscriptionId, priceId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
23137
+ description: `List all subscription items, optionally filtered by subscription or price. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_SUBSCRIPTION_ITEMS_FILTERS)}`,
23116
23138
  annotations: {
23117
23139
  title: "List subscription items",
23118
23140
  readOnlyHint: true,
@@ -23127,7 +23149,7 @@ var subscriptionItemTools = [
23127
23149
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
23128
23150
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
23129
23151
  }),
23130
- requiredFilters: ["subscriptionId", "priceId"],
23152
+ requiredFilters: LIST_SUBSCRIPTION_ITEMS_FILTERS,
23131
23153
  handler: listHandler("/subscription-items", { subscriptionId: "subscription_id", priceId: "price_id" })
23132
23154
  },
23133
23155
  {
@@ -23234,7 +23256,7 @@ var subscriptionTools = [
23234
23256
  {
23235
23257
  name: "ls_update_subscription",
23236
23258
  authorityClass: "recurring",
23237
- description: "Update a subscription. Can change the variant (plan switch), pause/unpause, set billing anchor, or update invoice details. Use ls_cancel_subscription for cancellation.",
23259
+ description: "Update a subscription. Can change the variant (plan switch), pause/unpause, set billing anchor, or update invoice details. Pausing (`pause: 'void'` or `'free'`) or switching plan (`variantId`) is customer-impacting and is treated as destructive (rate-limited and audited); resuming and the billing-neutral edits are not. Use ls_cancel_subscription for cancellation.",
23238
23260
  annotations: {
23239
23261
  title: "Update subscription",
23240
23262
  readOnlyHint: false,
@@ -23274,6 +23296,11 @@ var subscriptionTools = [
23274
23296
  if (input.invoiceImmediately !== void 0) attributes.invoice_immediately = input.invoiceImmediately;
23275
23297
  if (input.disableProrations !== void 0) attributes.disable_prorations = input.disableProrations;
23276
23298
  if (input.trialEndsAt !== void 0) attributes.trial_ends_at = input.trialEndsAt;
23299
+ if (Object.keys(attributes).length === 0) {
23300
+ throw new ToolInputError(
23301
+ "ls_update_subscription requires at least one of: variantId, pause, cancelled, billingAnchor, invoiceImmediately, disableProrations, trialEndsAt"
23302
+ );
23303
+ }
23277
23304
  return apiPatch(`/subscriptions/${encodePath(input.subscriptionId)}`, {
23278
23305
  data: {
23279
23306
  type: "subscriptions",
@@ -23304,6 +23331,7 @@ var subscriptionTools = [
23304
23331
  ];
23305
23332
 
23306
23333
  // src/tools/usage-records.ts
23334
+ var LIST_USAGE_RECORDS_FILTERS = ["subscriptionItemId"];
23307
23335
  var usageRecordTools = [
23308
23336
  {
23309
23337
  name: "ls_get_usage_record",
@@ -23325,7 +23353,7 @@ var usageRecordTools = [
23325
23353
  {
23326
23354
  name: "ls_list_usage_records",
23327
23355
  authorityClass: "read",
23328
- description: "List all usage records, optionally filtered by subscription item. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: subscriptionItemId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
23356
+ description: `List all usage records, optionally filtered by subscription item. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_USAGE_RECORDS_FILTERS)}`,
23329
23357
  annotations: {
23330
23358
  title: "List usage records",
23331
23359
  readOnlyHint: true,
@@ -23339,7 +23367,7 @@ var usageRecordTools = [
23339
23367
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
23340
23368
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
23341
23369
  }),
23342
- requiredFilters: ["subscriptionItemId"],
23370
+ requiredFilters: LIST_USAGE_RECORDS_FILTERS,
23343
23371
  handler: listHandler("/usage-records", { subscriptionItemId: "subscription_item_id" })
23344
23372
  },
23345
23373
  {
@@ -23402,6 +23430,7 @@ var userTools = [
23402
23430
  ];
23403
23431
 
23404
23432
  // src/tools/variants.ts
23433
+ var LIST_VARIANTS_FILTERS = ["productId"];
23405
23434
  var variantTools = [
23406
23435
  {
23407
23436
  name: "ls_get_variant",
@@ -23423,7 +23452,7 @@ var variantTools = [
23423
23452
  {
23424
23453
  name: "ls_list_variants",
23425
23454
  authorityClass: "read",
23426
- description: "List all variants, optionally filtered by product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. Cross-store note: when LEMONSQUEEZY_ALLOWED_STORE_IDS is set, this tool requires at least one of: productId. Even with that set, pair with a scoped LemonSqueezy API key for true cross-store enforcement -- the API key's visibility is the true boundary.",
23455
+ description: `List all variants, optionally filtered by product. Results are paginated \u2014 check meta.page in the response for currentPage, lastPage, and total. ${crossStoreFilterNote(LIST_VARIANTS_FILTERS)}`,
23427
23456
  annotations: {
23428
23457
  title: "List variants",
23429
23458
  readOnlyHint: true,
@@ -23437,7 +23466,7 @@ var variantTools = [
23437
23466
  pageNumber: external_exports.number().int().min(1).optional().describe("Page number (1-indexed)"),
23438
23467
  pageSize: external_exports.number().int().min(1).max(100).optional().describe("Results per page (1-100)")
23439
23468
  }),
23440
- requiredFilters: ["productId"],
23469
+ requiredFilters: LIST_VARIANTS_FILTERS,
23441
23470
  handler: listHandler("/variants", { productId: "product_id" })
23442
23471
  }
23443
23472
  ];
@@ -23544,7 +23573,7 @@ var webhookTools = [
23544
23573
  if (input.events !== void 0) attributes.events = input.events;
23545
23574
  if (input.secret !== void 0) attributes.secret = input.secret;
23546
23575
  if (Object.keys(attributes).length === 0) {
23547
- throw new Error("ls_update_webhook requires at least one of: url, events, secret");
23576
+ throw new ToolInputError("ls_update_webhook requires at least one of: url, events, secret");
23548
23577
  }
23549
23578
  return apiPatch(`/webhooks/${encodePath(input.webhookId)}`, {
23550
23579
  data: {
@@ -23615,30 +23644,32 @@ function looksLikeBearerToken(value) {
23615
23644
  if (value.length < 20) return false;
23616
23645
  return JWT_VALUE_RE.test(value);
23617
23646
  }
23618
- function redactInner(value, visited, depth) {
23647
+ function redactInner(value, ancestors, memo, depth) {
23619
23648
  if (depth > MAX_DEPTH) return CIRCULAR;
23620
23649
  if (typeof value === "string" && looksLikeBearerToken(value)) return REDACTED;
23621
23650
  if (value === null || typeof value !== "object") return value;
23622
- if (Array.isArray(value)) {
23623
- if (visited.has(value)) return CIRCULAR;
23624
- visited.add(value);
23625
- return value.map((item) => redactInner(item, visited, depth + 1));
23626
- }
23627
- if (!isPlainObject3(value)) return value;
23628
- if (visited.has(value)) return CIRCULAR;
23629
- visited.add(value);
23630
- const out = {};
23631
- for (const [key, val] of Object.entries(value)) {
23632
- if (SECRET_KEY_RE.test(key)) {
23633
- out[key] = REDACTED;
23634
- } else {
23635
- out[key] = redactInner(val, visited, depth + 1);
23651
+ const isArray = Array.isArray(value);
23652
+ if (!isArray && !isPlainObject3(value)) return value;
23653
+ if (ancestors.has(value)) return CIRCULAR;
23654
+ const cached3 = memo.get(value);
23655
+ if (cached3 !== void 0 && cached3.depth <= depth) return cached3.result;
23656
+ ancestors.add(value);
23657
+ let result;
23658
+ if (isArray) {
23659
+ result = value.map((item) => redactInner(item, ancestors, memo, depth + 1));
23660
+ } else {
23661
+ const out = {};
23662
+ for (const [key, val] of Object.entries(value)) {
23663
+ out[key] = SECRET_KEY_RE.test(key) ? REDACTED : redactInner(val, ancestors, memo, depth + 1);
23636
23664
  }
23665
+ result = out;
23637
23666
  }
23638
- return out;
23667
+ ancestors.delete(value);
23668
+ memo.set(value, { depth, result });
23669
+ return result;
23639
23670
  }
23640
23671
  function redactSecrets(input) {
23641
- return redactInner(input, /* @__PURE__ */ new WeakSet(), 0);
23672
+ return redactInner(input, /* @__PURE__ */ new WeakSet(), /* @__PURE__ */ new WeakMap(), 0);
23642
23673
  }
23643
23674
 
23644
23675
  // src/wrapper.ts
@@ -23660,10 +23691,23 @@ function createToolHandler(tool) {
23660
23691
  }
23661
23692
  const inputAsRecord = input;
23662
23693
  const toolAsRecord = tool;
23663
- const isDestructive = isDestructiveCall(toolAsRecord, inputAsRecord);
23694
+ let isDestructive;
23695
+ try {
23696
+ isDestructive = isDestructiveCall(toolAsRecord, inputAsRecord);
23697
+ } catch (predicateErr) {
23698
+ isDestructive = true;
23699
+ logEvent({
23700
+ event: "tool_call",
23701
+ tool: tool.name,
23702
+ status: "exception",
23703
+ latency_ms: 0,
23704
+ error: `isDestructive predicate for tool "${tool.name}" threw: ${predicateErr instanceof Error ? predicateErr.message : String(predicateErr)}. Treating the call as destructive.`
23705
+ });
23706
+ }
23664
23707
  const start = Date.now();
23665
23708
  try {
23666
23709
  checkClassAllowed(tool.authorityClass);
23710
+ tool.preflight?.(input);
23667
23711
  checkClassRateLimit(tool.authorityClass);
23668
23712
  if (isDestructive) checkDestructiveRateLimit();
23669
23713
  checkStoreScopedToolInput(toolAsRecord, inputAsRecord);
@@ -23709,7 +23753,10 @@ function createToolHandler(tool) {
23709
23753
  const errorEntry = {
23710
23754
  event: "tool_call",
23711
23755
  tool: tool.name,
23712
- status: err instanceof GuardrailError ? "guardrail_block" : "exception",
23756
+ // Three distinct buckets, because they mean three different things
23757
+ // to whoever is reading the log: operator policy refused the call,
23758
+ // the client sent a bad request, or something actually broke.
23759
+ status: err instanceof GuardrailError ? "guardrail_block" : err instanceof ToolInputError ? "validation_error" : "exception",
23713
23760
  latency_ms,
23714
23761
  error: message,
23715
23762
  audit: isDestructive ? true : void 0,
@@ -23742,7 +23789,7 @@ function readAuditLogResource(uri) {
23742
23789
  }
23743
23790
 
23744
23791
  // src/index.ts
23745
- var version2 = true ? "0.10.13" : (await null).createRequire(import.meta.url)("../package.json").version;
23792
+ var version2 = true ? "0.11.0" : createRequire(import.meta.url)("../package.json").version;
23746
23793
  var subcommand = process.argv[2];
23747
23794
  if (subcommand === "version" || subcommand === "--version") {
23748
23795
  console.log(version2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yawlabs/lemonsqueezy-mcp",
3
- "version": "0.10.13",
3
+ "version": "0.11.0",
4
4
  "mcpName": "io.github.YawLabs/lemonsqueezy-mcp",
5
5
  "description": "LemonSqueezy MCP server for managing your store from AI assistants",
6
6
  "license": "MIT",
@@ -36,6 +36,8 @@
36
36
  "test:integration": "npm run build && node --test dist/integration/*.test.js",
37
37
  "lint": "biome check src/",
38
38
  "lint:fix": "biome check --write src/",
39
+ "check:oam": "oam check",
40
+ "build:binary:oam": "node scripts/build-binary.mjs --oam",
39
41
  "gen:containerfile": "node scripts/sync-containerfile.mjs",
40
42
  "check:containerfile": "node scripts/sync-containerfile.mjs --check",
41
43
  "prepublishOnly": "npm run build"