hermoso 0.1.142 → 0.1.152
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/README.md +59 -3
- package/bin/hermoso.mjs +6 -1
- package/mcp/client.mjs +64 -4
- package/mcp/http.mjs +25 -9
- package/mcp/tools.mjs +1738 -111
- package/package.json +2 -2
- package/skills/hermoso-ad-from-brand/SKILL.md +1 -1
- package/skills/hermoso-generate/SKILL.md +1 -1
- package/skills/hermoso-product-photoshoot/SKILL.md +1 -1
- package/skills/hermoso-research/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
|
|
|
5
5
|
composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
|
|
6
6
|
campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
|
|
7
7
|
|
|
8
|
-
**
|
|
8
|
+
**709 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
9
9
|
catalog with exact per-render credit costs plus the full capability map.
|
|
10
10
|
|
|
11
11
|
**What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
|
|
@@ -22,6 +22,47 @@ ad-build tool accepts); build and read campaigns on your own ad accounts with yo
|
|
|
22
22
|
competitors with no brand drafted and no channel connected; or generate a file with nothing connected at all and
|
|
23
23
|
just download it. Use the one piece you need, or all of it together.
|
|
24
24
|
|
|
25
|
+
## Which surface should your agent use?
|
|
26
|
+
|
|
27
|
+
Two shapes, and the right one is decided by **what your client can do**, not by which we prefer.
|
|
28
|
+
|
|
29
|
+
| Your client | Use | Why |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| **Runs in a browser** — Claude.ai, ChatGPT, Claude Desktop | the hosted connector `https://app.hermoso.ai/mcp` | It cannot spawn a local process, so a URL is the only shape it has. Nothing to install, no key to paste, and the full toolset arrives with your saved brand context. This is the right answer for these clients, not a lesser one. |
|
|
32
|
+
| **Can run a shell** — Claude Code, Cursor, Codex, Cline, OpenClaw, Hermes, your own scripts | the CLI, `npm install -g hermoso` | A tool manifest is loaded into every session whether or not a tool is called. A shell command costs nothing until it runs, and it reaches **every** tool rather than the default roster. |
|
|
33
|
+
|
|
34
|
+
**The measured difference** (2026-08-24, counted as real tool definitions rather than estimated from bytes):
|
|
35
|
+
|
|
36
|
+
| | tools in range | loaded per session |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| Hosted connector, default roster | 291 | **166,043 tokens** |
|
|
39
|
+
| Hosted connector, `?tools=all` | 703 | **456,392 tokens** |
|
|
40
|
+
| stdio server (`npx -y hermoso mcp`) | 269 | **153,257 tokens** |
|
|
41
|
+
| **CLI** | **all 681** | **0** |
|
|
42
|
+
|
|
43
|
+
The CLI answers the same questions on demand instead, and only when asked:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx -y hermoso tools --search reddit # every matching tool, name + one line 2,459 tokens
|
|
47
|
+
npx -y hermoso tools plan_ad # one tool's full argument schema 633 tokens
|
|
48
|
+
npx -y hermoso call plan_ad --json '{"product":"…"}' # run it
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
So a terminal agent reaches its first call in roughly **3.4K tokens with the whole roster in range**, against
|
|
52
|
+
**153K for a fraction of it**. `tools` and `tools <name>` read a registry bundled in the package — no key, no
|
|
53
|
+
network, no sign-in — so an agent can browse the entire product before anyone signs in. Only `call` spends, and
|
|
54
|
+
only that needs `hermoso auth login` once.
|
|
55
|
+
|
|
56
|
+
**Both at once is fine, and is what we suggest for Claude Code.** One `hermoso auth login` covers the CLI *and*
|
|
57
|
+
lets `claude mcp add hermoso -- npx -y hermoso mcp` pick the key up with no `env` block, so the agent can reach for
|
|
58
|
+
a native tool when it wants structured results and shell out when it wants breadth. If you only want one, take the
|
|
59
|
+
CLI: it covers strictly more.
|
|
60
|
+
|
|
61
|
+
**When the connector is still the better trade on a shell-capable client:** a session that is going to make many
|
|
62
|
+
calls into one area. `enable_tools({groups:['ads']})` turns campaign management on in a single free call and the
|
|
63
|
+
tools are then native — no shell quoting, structured results. One shell round trip beats loading a 221K-token
|
|
64
|
+
group for a single tool; the reverse is true once a session settles into that area.
|
|
65
|
+
|
|
25
66
|
## Instant: the hosted Claude.ai connector
|
|
26
67
|
|
|
27
68
|
Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
|
|
@@ -77,7 +118,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
77
118
|
|
|
78
119
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
79
120
|
|
|
80
|
-
### What the
|
|
121
|
+
### What the 709 tools cover
|
|
81
122
|
|
|
82
123
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
83
124
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
|
@@ -180,6 +221,21 @@ hermoso research "Liquid Death’s longest-running ads"
|
|
|
180
221
|
|
|
181
222
|
Add `--json` to any command for machine output.
|
|
182
223
|
|
|
224
|
+
**Those shortcuts are the common path, not the limit.** Every tool the MCP server has is reachable here too,
|
|
225
|
+
including the ad-campaign and analytics groups a connector leaves out of its default roster:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
hermoso tools # every tool, grouped, name + one line
|
|
229
|
+
hermoso tools --group ads --search reddit # narrow it
|
|
230
|
+
hermoso tools create_meta_campaign # that tool's full argument schema
|
|
231
|
+
hermoso call create_meta_campaign --json '{"name":"…"}' # run it
|
|
232
|
+
hermoso create_meta_campaign --name "…" # same thing, shorter
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`call` goes through the same handler, the same argument validation and the same confirm/spend gates the MCP
|
|
236
|
+
server uses — there is no second implementation to drift. `tools` and `tools <name>` read a registry bundled in
|
|
237
|
+
the package, so they need no key, no network and no sign-in.
|
|
238
|
+
|
|
183
239
|
## 3. Claude skills — slash commands that wrap the CLI
|
|
184
240
|
|
|
185
241
|
`skills/` holds four installable skills: `hermoso-generate`, `hermoso-ad-from-brand`,
|
|
@@ -198,7 +254,7 @@ Then invoke `/hermoso-ad-from-brand an ad for yourbrand.com — our hero product
|
|
|
198
254
|
| `HERMOSO_API_BASE` | The Hermoso API origin (default `https://app.hermoso.ai` — set `http://localhost:3000` if you run the app yourself) |
|
|
199
255
|
| `HERMOSO_TOKEN` | Bearer agent key (`hmk_…`) — required against the hosted app |
|
|
200
256
|
| `HERMOSO_PROFILE` | Brand-workspace id, for accounts with multiple brand profiles |
|
|
201
|
-
| `HERMOSO_OWNER` | Only for a brand **another account shared with you** (a team workspace): the owning account id. Set it together with `HERMOSO_PROFILE`, and set `HERMOSO_PROFILE` to that workspace's **profileUuid** — a brand's short slug is refused. Run `list_brands` (or `hermoso
|
|
257
|
+
| `HERMOSO_OWNER` | Only for a brand **another account shared with you** (a team workspace): the owning account id. Set it together with `HERMOSO_PROFILE`, and set `HERMOSO_PROFILE` to that workspace's **profileUuid** — a brand's short slug is refused. Run `list_brands` (or `hermoso list_brands` from the CLI) to print both values for every workspace you can enter. The server re-authorizes the pair on every request, so a wrong value is refused, never trusted. |
|
|
202
258
|
|
|
203
259
|
`mcp/http.mjs` is the hosted remote-connector transport (paste-a-URL into Claude.ai → Connectors). It ships in
|
|
204
260
|
this repo for transparency and refuses to mount without authenticated identity — no anonymous spend, ever.
|
package/bin/hermoso.mjs
CHANGED
|
@@ -22,6 +22,11 @@
|
|
|
22
22
|
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
|
|
23
23
|
import os from 'node:os';
|
|
24
24
|
import path from 'node:path';
|
|
25
|
+
import { createRequire } from 'node:module';
|
|
26
|
+
// READ THE REAL VERSION. This line printed a hard-coded "1.0.0" while package.json shipped 0.1.151 — so the
|
|
27
|
+
// one command a user runs to find out what they have installed reported a version that has never existed.
|
|
28
|
+
// Read from package.json so it cannot drift again; a bump is already a release step, and this now follows it.
|
|
29
|
+
const CLI_VERSION = (() => { try { return createRequire(import.meta.url)('../package.json').version; } catch { return '0.0.0'; } })();
|
|
25
30
|
|
|
26
31
|
const CONFIG_DIR = path.join(os.homedir(), '.hermoso');
|
|
27
32
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
@@ -78,7 +83,7 @@ async function main() {
|
|
|
78
83
|
return console.log(`✓ Saved. API: ${apiBase}${cfg.token ? ' · token stored' : ''}`);
|
|
79
84
|
}
|
|
80
85
|
if (group === 'version' || flags.version) {
|
|
81
|
-
console.log(`hermoso-cli
|
|
86
|
+
console.log(`hermoso-cli ${CLI_VERSION} · API ${cfg.apiBase || process.env.HERMOSO_API_BASE || 'https://app.hermoso.ai'} · ${cfg.token ? 'authed' : 'no token — run: hermoso auth login --token <key>'}`);
|
|
82
87
|
return;
|
|
83
88
|
}
|
|
84
89
|
|
package/mcp/client.mjs
CHANGED
|
@@ -240,11 +240,71 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
|
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
242
|
|
|
243
|
-
//
|
|
243
|
+
// ── A REFERENCE THAT IS NOT A URL IS A LOCAL PATH, AND ON THE HOSTED CONNECTOR THERE IS NO SUCH THING ──────────
|
|
244
|
+
// ChatGPT renders an image in its OWN sandbox and hands that path straight to generate_video. We opened it and
|
|
245
|
+
// leaked `ENOENT: no such file or directory, open 'sandbox:/mnt/data/…'`, a Node filesystem error sitting where an
|
|
246
|
+
// instruction belongs (reproduced on the live hosted connector 2026-08-24). `upload_file` has answered this
|
|
247
|
+
// correctly since it was written; this says the same thing at the ONE seam every render tool with a file-ish input
|
|
248
|
+
// already passes through (generate_image refImages, make_thumbnail faceImages + logo, generate_video refImage,
|
|
249
|
+
// generate_avatar image), so a render tool added tomorrow inherits the refusal instead of having to remember it.
|
|
250
|
+
// TWO DISTINCT REASONS, deliberately not collapsed into one:
|
|
251
|
+
// • A NON-FILE URI SCHEME (sandbox:, file:, blob:, gs:, s3:) is never a readable path on ANY surface, so it is
|
|
252
|
+
// refused on stdio too. `sandbox:/mnt/data/x.png` does not fail because we are hosted; it fails because that
|
|
253
|
+
// string was never a path.
|
|
254
|
+
// • A BARE PATH while hosted names the caller's disk, which we cannot see. Refusing is not merely the politer
|
|
255
|
+
// answer: hosted MCP tool code runs INSIDE our own container, so readFile() there reads OUR filesystem, and a
|
|
256
|
+
// path that happened to exist would be base64'd into a data: URI and shipped to a video model.
|
|
257
|
+
// A Windows drive letter (`C:\\shots\\hero.png`) looks exactly like a one-character scheme, so a scheme needs two or
|
|
258
|
+
// more characters to count. That path must still open on stdio.
|
|
259
|
+
const REF_SCHEME_NOTE = {
|
|
260
|
+
sandbox: 'That is a path inside your own sandbox, which only your process can read.',
|
|
261
|
+
file: 'A file:// URL is not something I can open.',
|
|
262
|
+
blob: 'A blob: URL only exists inside the browser tab that created it.',
|
|
263
|
+
};
|
|
264
|
+
// VERIFIED ON THE LIVE HOSTED CONNECTOR 2026-08-24 rather than assumed: upload_file with `dataUri` returned a real
|
|
265
|
+
// assets.hermoso.ai URL, and upload_file with `path` refused with its own message. So "call upload_file first" on
|
|
266
|
+
// its own would walk the caller into the same wall. The SOURCE is the part that has to be named.
|
|
267
|
+
const REF_WAYS_OUT = 'Send the bytes instead of a path. Pass the image as a `data:` URI (data:image/png;base64,…), or put it at a public https URL and pass that. Both work here. If you want a reusable Hermoso URL first, call upload_file with `dataUri` or `url` (its `path` source is refused on the hosted connector for this same reason).';
|
|
268
|
+
|
|
269
|
+
// PURE, so tools/local-ref-refusal-check.mjs runs the real decision instead of reading it.
|
|
270
|
+
export function localRefVerdict(src, { remote = false } = {}) {
|
|
271
|
+
const s = String(src ?? '').trim();
|
|
272
|
+
if (!s) return { action: 'skip' };
|
|
273
|
+
if (/^(https?:|data:)/i.test(s)) return { action: 'pass', value: s };
|
|
274
|
+
const m = /^([a-z][a-z0-9+.-]+):/i.exec(s);
|
|
275
|
+
if (m) return { action: 'refuse', reason: 'scheme', scheme: m[1].toLowerCase() };
|
|
276
|
+
if (remote) return { action: 'refuse', reason: 'hosted' };
|
|
277
|
+
return { action: 'read' };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function localRefMessage(src, verdict, { remote = false } = {}) {
|
|
281
|
+
const s = String(src ?? '').trim().slice(0, 120);
|
|
282
|
+
const head = verdict.reason === 'scheme'
|
|
283
|
+
? `\`${s}\` is a ${verdict.scheme}: URI, not a file I can open.${REF_SCHEME_NOTE[verdict.scheme] ? ` ${REF_SCHEME_NOTE[verdict.scheme]}` : ''}`
|
|
284
|
+
: `\`${s}\` is a local file path, and on the hosted connector I cannot see your disk.`;
|
|
285
|
+
const tail = verdict.reason === 'scheme' && !remote ? ' If you meant a file on this machine, pass its real filesystem path.' : '';
|
|
286
|
+
return `${head} Nothing was rendered and nothing was charged.\n\n${REF_WAYS_OUT}${tail}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// `status: 400` is what files this as `user` on the error board. /api/errors/report rebuilds the error from
|
|
290
|
+
// ALLOWLISTED fields and deliberately will not read a `_userInput` marker off an untrusted reporter (a caller could
|
|
291
|
+
// otherwise file its own crash under "the user's fault"), so the status is the honest channel: 400 is in the
|
|
292
|
+
// ledger's AUTHORED_USER_STATUSES. `_userInput` is set too, for the in-process classifier, and it is the accurate
|
|
293
|
+
// marker of the two here since this is a bad ARGUMENT rather than a capability we decline to offer.
|
|
294
|
+
const refRefusal = (msg) => Object.assign(new Error(msg), { status: 400, _userInput: true });
|
|
295
|
+
|
|
296
|
+
// Read a local image path → data URI (so --ref local files force Nano-Banana compositing); pass http(s) and data
|
|
297
|
+
// URLs through; refuse anything we cannot open, with the way out named.
|
|
244
298
|
export async function toRef(srcOrPath) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
299
|
+
const remote = isRemote();
|
|
300
|
+
const v = localRefVerdict(srcOrPath, { remote });
|
|
301
|
+
if (v.action === 'skip') return null;
|
|
302
|
+
if (v.action === 'pass') return v.value;
|
|
303
|
+
if (v.action === 'refuse') throw refRefusal(localRefMessage(srcOrPath, v, { remote }));
|
|
304
|
+
let buf;
|
|
305
|
+
// Even on stdio a raw ENOENT is a filesystem error where an instruction belongs.
|
|
306
|
+
try { buf = await readFile(srcOrPath); }
|
|
307
|
+
catch (e) { throw refRefusal(`I couldn't open \`${String(srcOrPath).trim().slice(0, 120)}\` (${e?.code || 'the read failed'}). Nothing was rendered and nothing was charged.\n\nCheck the path is right, or send the bytes instead: ${REF_WAYS_OUT}`); }
|
|
248
308
|
const ext = path.extname(srcOrPath).toLowerCase().replace('.', '');
|
|
249
309
|
const mime = ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/jpeg';
|
|
250
310
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
package/mcp/http.mjs
CHANGED
|
@@ -66,22 +66,38 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
66
66
|
|
|
67
67
|
// Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
|
|
68
68
|
//
|
|
69
|
-
// ── A SESSION
|
|
70
|
-
// registerTools()
|
|
71
|
-
// (measured, node --expose-gc, 25 instances
|
|
72
|
-
//
|
|
73
|
-
//
|
|
69
|
+
// ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
|
|
70
|
+
// registerTools() used to rebuild all 681 tool definitions with their zod schemas on EVERY `initialize`:
|
|
71
|
+
// **114.36 MB of RETAINED heap per McpServer** (measured, node --expose-gc, 25 instances; 114.48 MB measured
|
|
72
|
+
// again end-to-end through this very transport). The older note here said ~36 MB — true at the 248-tool roster
|
|
73
|
+
// it was written against, and 3.2x stale by August. This map used to be unbounded and never expired — the only
|
|
74
|
+
// removal was transport.onclose, which never fires for a client that simply goes away. Prod took 238
|
|
75
|
+
// `initialize` handshakes in 49 minutes on a 4 GiB, maxScale=1 instance and died of
|
|
74
76
|
// `FATAL ERROR: Reached heap limit` six times in that hour, every crash a full outage.
|
|
75
77
|
//
|
|
78
|
+
// THE CAP AND THE TTL WERE NEVER THE FIX, AND 2026-08-24 PROVED IT: they worked exactly as designed (peak
|
|
79
|
+
// concurrent sessions measured at exactly SESSION_MAX) and prod still crashed four times in twelve hours,
|
|
80
|
+
// because the cost is CHURN, not retention — one client opened ~175 sessions in 65 minutes. Worse, the cap was
|
|
81
|
+
// FEEDING the churn: 157 of 258 session closures in a 3-day window were 'over cap', each one answering that
|
|
82
|
+
// client's next call with a 404, which obliges it to re-initialize, which allocates another full roster and
|
|
83
|
+
// evicts somebody else. A self-sustaining spiral. The durable fix is in mcp/tools.mjs: the tool definitions are
|
|
84
|
+
// static per process, so they are built ONCE and replayed. A session now costs **3.06-3.19 MB** — 36x less —
|
|
85
|
+
// and it is the per-`initialize` figure, not the per-live-session one, that decides whether this survives a
|
|
86
|
+
// retry storm. See tools/mcp-tool-canon-check.mjs.
|
|
87
|
+
//
|
|
76
88
|
// The 400-on-a-session-miss below was the ACCELERANT: a client that cannot re-initialize opens a NEW session
|
|
77
89
|
// instead of reusing its own, so the leak fed itself. Both are fixed here — bound + expire the map, and answer
|
|
78
90
|
// the one status code that obliges a client to re-initialize.
|
|
79
|
-
|
|
91
|
+
// 16 was sized against the old 114 MB session (~1.8 GB). At 3.1 MB it buys ~50 MB, so the cap is now far more
|
|
92
|
+
// conservative than the heap requires — deliberately left alone here so the memory fix lands as ONE measurable
|
|
93
|
+
// change; it is env-tunable with no deploy (`--update-env-vars MCP_SESSION_MAX=64` ≈ 200 MB) and raising it is
|
|
94
|
+
// what finally stops the over-cap eviction churn described above.
|
|
95
|
+
const SESSION_MAX = Math.max(2, Number(process.env.MCP_SESSION_MAX || 16));
|
|
80
96
|
const SESSION_IDLE_MS = Math.max(1000, Number(process.env.MCP_SESSION_IDLE_MS || 30 * 60e3)); // 1s floor so the expiry is TESTABLE; a short TTL is merely wasteful now that eviction is recoverable
|
|
81
97
|
const sessions = new Map(); // mcp-session-id -> { transport, server, user, lastSeen } (insertion-ordered = LRU)
|
|
82
98
|
const challenge = (res) => res.status(401).set('WWW-Authenticate', `Bearer resource_metadata="${BASE}/.well-known/oauth-protected-resource"`).json({ error: 'Authentication required' });
|
|
83
99
|
|
|
84
|
-
// Tear a session down for real — dropping the map entry alone would leave the
|
|
100
|
+
// Tear a session down for real — dropping the map entry alone would leave the McpServer reachable from
|
|
85
101
|
// the transport's own callbacks. Deleting FIRST makes this re-entrant-safe: transport.close() fires onclose,
|
|
86
102
|
// which calls back in here, and the second pass is a no-op.
|
|
87
103
|
function dropSession(id, why) {
|
|
@@ -195,7 +211,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
195
211
|
// liberal in what we accept, because the entire point of this fix is that nothing here bricks a client.
|
|
196
212
|
if (sid && !init) return sessionGone(res);
|
|
197
213
|
// Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
|
|
198
|
-
// answered here so we never
|
|
214
|
+
// answered here so we never build a server whose only job would be to reject the request.
|
|
199
215
|
if (!init) return needSession(res);
|
|
200
216
|
const scope = scopeFor(req, res);
|
|
201
217
|
if (scope === false) return; // unknown group — already answered 400, and nothing was allocated
|
|
@@ -219,7 +235,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
219
235
|
await server.connect(transport);
|
|
220
236
|
// If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
|
|
221
237
|
// objects are otherwise reachable only from this request's still-open response — close them explicitly
|
|
222
|
-
// rather than leaving
|
|
238
|
+
// rather than leaving a session pinned by a dead socket.
|
|
223
239
|
res.on('close', () => { if (!transport.sessionId || !sessions.has(transport.sessionId)) { try { transport.close(); } catch {} try { server.close(); } catch {} } });
|
|
224
240
|
} else {
|
|
225
241
|
// Touch = LRU. Re-inserting moves the key to the end of the Map's insertion order, so the cap evicts the
|