@yawlabs/lemonsqueezy-mcp 0.11.0 → 0.12.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 +20 -0
- package/bin/lemonsqueezy-mcp.mjs +169 -0
- package/dist/index.js +6 -2
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.12.0] — 2026-08-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Runtime launcher at `bin/lemonsqueezy-mcp.mjs`: the published `lemonsqueezy-mcp` command now prefers the [oam](https://oamjs.org) runtime and falls back to Node. `LEMONSQUEEZY_MCP_RUNTIME` selects (`auto` / `oam` / `node`) and `OAM_BIN` overrides discovery. Both paths verified against the MCP surface — handshake plus all 64 tools — and behave identically. The fallback does **not** re-exec Node: npm has already started Node to run the launcher, so it is an in-process `import()` with no extra spawn.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- `.gitignore` excludes `bin/*` rather than `bin/`, so the launcher can be re-included with a negation. A negation cannot undo a directory-level exclusion — that trap shipped a broken `bin` in postgres-mcp, where the launcher was untracked and absent from every fresh clone.
|
|
12
|
+
- `scripts/build-binary.mjs` pins the CLI source entry instead of deriving it from `bin`'s value, which would have resolved to `bin/lemonsqueezy-mcp.ts` once `bin` moved to the launcher — the breakage postgres-mcp shipped in its 0.9.0.
|
|
13
|
+
|
|
3
14
|
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
15
|
|
|
16
|
+
## [0.11.1] -- 2026-08-07
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **A 4xx whose error envelope carries no `detail` no longer dumps raw JSON at the caller.** LemonSqueezy's real 404 is `{"jsonapi":{"version":"1.0"},"errors":[{"status":"404","title":"Not Found"}]}` -- `title`, no `detail`. The message chain read `detail` then the License API's bare `error`, so this shape fell all the way through to the raw response body and an agent asking for a missing store got a JSON blob instead of a reason. `title` is now read between the two.
|
|
21
|
+
|
|
22
|
+
Long-standing, not new in 0.11.0, and invisible to the unit suite because every fixture in it (and every one added during the 0.11.0 audit) used `detail`. Caught by a read-only call against the live API. Both shapes are now pinned, including the precedence when `detail` and `title` are both present.
|
|
23
|
+
|
|
5
24
|
## [0.11.0] -- 2026-08-07
|
|
6
25
|
|
|
7
26
|
### Security
|
|
@@ -472,6 +491,7 @@ Hardening pass for unattended automation against live billing flows.
|
|
|
472
491
|
Initial release. 59 tools covering all 17 LemonSqueezy API resources.
|
|
473
492
|
|
|
474
493
|
[Unreleased]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.9...HEAD
|
|
494
|
+
[0.11.1]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.11.0...v0.11.1
|
|
475
495
|
[0.11.0]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.13...v0.11.0
|
|
476
496
|
[0.10.13]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.12...v0.10.13
|
|
477
497
|
[0.10.12]: https://github.com/YawLabs/lemonsqueezy-mcp/compare/v0.10.11...v0.10.12
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Runtime launcher for @yawlabs/lemonsqueezy-mcp.
|
|
4
|
+
*
|
|
5
|
+
* Prefers the oam runtime (https://oamjs.org) and falls back to the Node
|
|
6
|
+
* process already running this file.
|
|
7
|
+
*
|
|
8
|
+
*
|
|
9
|
+
* WHY THE FALLBACK COSTS NOTHING
|
|
10
|
+
* npm has already started Node to run this launcher, so falling back is a
|
|
11
|
+
* plain `import()` of the server into THIS process: no extra spawn, no extra
|
|
12
|
+
* startup, byte-identical to invoking dist/index.js directly. Discovery is
|
|
13
|
+
* stat-only -- never a subprocess -- so the miss case stays sub-millisecond.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE OAM PATH COSTS
|
|
16
|
+
* Reaching oam through an npm `bin` means Node boots first and oam boots
|
|
17
|
+
* second, so the launcher is slower than either runtime alone. Measured on
|
|
18
|
+
* npmjs-mcp (windows-arm64, n=12 medians, spawn to first MCP initialize):
|
|
19
|
+
* oam 116ms, node 172ms, launcher 243ms. oam is the fastest runtime and the
|
|
20
|
+
* launcher is the slowest path -- it exists for `npx` convenience.
|
|
21
|
+
*
|
|
22
|
+
* For an MCP host config, point straight at oam and skip this file:
|
|
23
|
+
* { "command": "oam", "args": ["run", "<abs>/dist/index.js"] }
|
|
24
|
+
*
|
|
25
|
+
* SELECTION
|
|
26
|
+
* LEMONSQUEEZY_MCP_RUNTIME=oam require oam; fail loudly if it is missing
|
|
27
|
+
* LEMONSQUEEZY_MCP_RUNTIME=node never use oam
|
|
28
|
+
* LEMONSQUEEZY_MCP_RUNTIME=auto prefer oam, silently fall back (default)
|
|
29
|
+
* OAM_BIN=/path/to/oam explicit binary, checked before any discovery
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { spawn } from "node:child_process";
|
|
33
|
+
import { existsSync } from "node:fs";
|
|
34
|
+
import { constants, homedir } from "node:os";
|
|
35
|
+
import { delimiter, join } from "node:path";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
// Two forms, deliberately. `import()` on Windows REJECTS a bare `C:\...` path
|
|
39
|
+
// with ERR_UNSUPPORTED_ESM_URL_SCHEME (it reads `c:` as a protocol), so the
|
|
40
|
+
// in-process fallback must use the file:// URL. spawn() needs a real path.
|
|
41
|
+
const SERVER_URL = new URL("../dist/index.js", import.meta.url);
|
|
42
|
+
const SERVER_ENTRY = fileURLToPath(SERVER_URL);
|
|
43
|
+
const isWin = process.platform === "win32";
|
|
44
|
+
const exe = isWin ? "oam.exe" : "oam";
|
|
45
|
+
|
|
46
|
+
/** Locate an oam binary, or null. Every branch is a stat, never a subprocess. */
|
|
47
|
+
function findOam() {
|
|
48
|
+
// 1. Explicit override wins and is never second-guessed.
|
|
49
|
+
const override = process.env.OAM_BIN;
|
|
50
|
+
if (override) return existsSync(override) ? override : null;
|
|
51
|
+
|
|
52
|
+
// 2. Installed locations, BEFORE PATH. Someone who develops oam itself
|
|
53
|
+
// usually has oam/target/release on PATH, and a build directory is the
|
|
54
|
+
// wrong thing for a user-facing launcher to bind to: cargo replaces the
|
|
55
|
+
// binary underneath running processes, and the dev build is not the
|
|
56
|
+
// release the user installed. Preferring the installed copy makes the
|
|
57
|
+
// default path "what a normal user has", and OAM_BIN remains the way to
|
|
58
|
+
// point deliberately at a dev build.
|
|
59
|
+
//
|
|
60
|
+
// Both forms are checked on Windows: the installer defaults to
|
|
61
|
+
// %LOCALAPPDATA%oamin there, but oam's docs name ~/.oam/bin first and
|
|
62
|
+
// OAM_INSTALL_DIR can pick either, so checking one silently misses a real
|
|
63
|
+
// install.
|
|
64
|
+
const installed = [join(homedir(), ".oam", "bin", exe)];
|
|
65
|
+
if (isWin) {
|
|
66
|
+
installed.unshift(join(process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "oam", "bin", exe));
|
|
67
|
+
}
|
|
68
|
+
for (const candidate of installed) {
|
|
69
|
+
if (existsSync(candidate)) return candidate;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. PATH, resolved manually rather than by spawning `which`/`where`, which
|
|
73
|
+
// would cost a subprocess on every launch just to decide whether to spawn.
|
|
74
|
+
const pathExt = isWin ? (process.env.PATHEXT ?? ".EXE").split(";").filter(Boolean) : [""];
|
|
75
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
76
|
+
if (!dir) continue;
|
|
77
|
+
for (const ext of isWin ? pathExt : [""]) {
|
|
78
|
+
const candidate = join(dir, isWin ? `oam${ext.toLowerCase()}` : "oam");
|
|
79
|
+
if (existsSync(candidate)) return candidate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Run the server in THIS process. The zero-overhead fallback. */
|
|
87
|
+
async function runInProcess() {
|
|
88
|
+
// A server may gate its bootstrap on being the process ENTRY POINT --
|
|
89
|
+
// `import.meta.url === pathToFileURL(process.argv[1]).href` -- so that its own
|
|
90
|
+
// test file can import the module for unit tests without connecting a stdio
|
|
91
|
+
// transport. aws-mcp does exactly this. Importing the server here would leave
|
|
92
|
+
// argv[1] pointing at THIS launcher, the guard would read false, and the
|
|
93
|
+
// server would load but never serve: the MCP handshake just hangs.
|
|
94
|
+
//
|
|
95
|
+
// Point argv[1] at the server first, so the in-process path is
|
|
96
|
+
// indistinguishable from having executed the file directly. The spawn path
|
|
97
|
+
// needs no equivalent -- there argv[1] is already the server.
|
|
98
|
+
process.argv[1] = SERVER_ENTRY;
|
|
99
|
+
await import(SERVER_URL.href);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mode = (process.env.LEMONSQUEEZY_MCP_RUNTIME ?? "auto").toLowerCase();
|
|
103
|
+
|
|
104
|
+
if (mode === "node") {
|
|
105
|
+
await runInProcess();
|
|
106
|
+
} else {
|
|
107
|
+
const oam = findOam();
|
|
108
|
+
|
|
109
|
+
if (!oam) {
|
|
110
|
+
if (mode === "oam") {
|
|
111
|
+
// Explicitly demanded, so this is a real misconfiguration. writeSync
|
|
112
|
+
// because stderr is async for TTYs/pipes on Windows and process.exit
|
|
113
|
+
// truncates pending writes.
|
|
114
|
+
const { writeSync } = await import("node:fs");
|
|
115
|
+
writeSync(
|
|
116
|
+
2,
|
|
117
|
+
"lemonsqueezy-mcp: LEMONSQUEEZY_MCP_RUNTIME=oam but no oam binary was found.\n" +
|
|
118
|
+
"Install from https://oamjs.org, set OAM_BIN=/path/to/oam, or use LEMONSQUEEZY_MCP_RUNTIME=node.\n",
|
|
119
|
+
);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
await runInProcess();
|
|
123
|
+
} else {
|
|
124
|
+
// `--` separates oam's own flags from the script's argv, so `lemonsqueezy-mcp
|
|
125
|
+
// --version` and any host-supplied flags survive the hop unchanged.
|
|
126
|
+
const child = spawn(oam, ["run", SERVER_ENTRY, "--", ...process.argv.slice(2)], {
|
|
127
|
+
// inherit keeps the SAME fds, so MCP's newline-delimited JSON framing on
|
|
128
|
+
// stdin/stdout is untouched and the host's stdin-close still reaches the
|
|
129
|
+
// server's shutdown path.
|
|
130
|
+
stdio: "inherit",
|
|
131
|
+
env: process.env,
|
|
132
|
+
windowsHide: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// If oam cannot be executed at all (deleted between the stat and the spawn,
|
|
136
|
+
// wrong arch, permission), fall back rather than failing the whole server.
|
|
137
|
+
// `spawned` prevents falling back AFTER the child started, which would
|
|
138
|
+
// double-start the server on the same stdio.
|
|
139
|
+
let spawned = false;
|
|
140
|
+
child.on("spawn", () => {
|
|
141
|
+
spawned = true;
|
|
142
|
+
});
|
|
143
|
+
child.on("error", (err) => {
|
|
144
|
+
if (spawned) return;
|
|
145
|
+
if (mode === "oam") {
|
|
146
|
+
process.stderr.write(`lemonsqueezy-mcp: failed to launch oam (${err.message})\n`);
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
void runInProcess();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Forward termination so the server's own shutdown path runs in the child
|
|
153
|
+
// rather than the child being orphaned. No-op on Windows, harmless to add.
|
|
154
|
+
for (const sig of ["SIGINT", "SIGTERM"]) {
|
|
155
|
+
process.on(sig, () => {
|
|
156
|
+
if (!child.killed) child.kill(sig);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
child.on("exit", (code, signal) => {
|
|
161
|
+
// Mirror the child's fate: a signal death becomes 128+n so callers see a
|
|
162
|
+
// conventional shell exit status rather than a bare 0.
|
|
163
|
+
if (signal) {
|
|
164
|
+
process.exit(128 + (constants.signals[signal] ?? 15));
|
|
165
|
+
}
|
|
166
|
+
process.exit(code ?? 0);
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -21626,11 +21626,15 @@ function buildQuery(params) {
|
|
|
21626
21626
|
function decorateError(error2, requestId) {
|
|
21627
21627
|
return requestId ? `${error2} (request_id: ${requestId})` : error2;
|
|
21628
21628
|
}
|
|
21629
|
+
function extractErrorMessage(parsed) {
|
|
21630
|
+
const first = parsed.errors?.[0];
|
|
21631
|
+
return first?.detail ?? first?.title ?? parsed.error ?? null;
|
|
21632
|
+
}
|
|
21629
21633
|
async function handleErrorResponse(res, route, latency_ms, requestId) {
|
|
21630
21634
|
const errorBody = await res.text();
|
|
21631
21635
|
try {
|
|
21632
21636
|
const parsed = JSON.parse(errorBody);
|
|
21633
|
-
const detail = parsed
|
|
21637
|
+
const detail = extractErrorMessage(parsed) ?? errorBody;
|
|
21634
21638
|
logEvent({
|
|
21635
21639
|
event: "http_call",
|
|
21636
21640
|
method: route.method,
|
|
@@ -23789,7 +23793,7 @@ function readAuditLogResource(uri) {
|
|
|
23789
23793
|
}
|
|
23790
23794
|
|
|
23791
23795
|
// src/index.ts
|
|
23792
|
-
var version2 = true ? "0.
|
|
23796
|
+
var version2 = true ? "0.12.0" : createRequire(import.meta.url)("../package.json").version;
|
|
23793
23797
|
var subcommand = process.argv[2];
|
|
23794
23798
|
if (subcommand === "version" || subcommand === "--version") {
|
|
23795
23799
|
console.log(version2);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yawlabs/lemonsqueezy-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.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",
|
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
"type": "module",
|
|
23
23
|
"main": "dist/index.js",
|
|
24
24
|
"bin": {
|
|
25
|
-
"lemonsqueezy-mcp": "
|
|
25
|
+
"lemonsqueezy-mcp": "bin/lemonsqueezy-mcp.mjs"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
+
"bin/lemonsqueezy-mcp.mjs",
|
|
28
29
|
"dist/index.js",
|
|
29
30
|
"CHANGELOG.md"
|
|
30
31
|
],
|