hermoso 0.1.162 → 0.1.177
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 +2 -2
- package/mcp/client.mjs +68 -24
- package/mcp/tools.mjs +408 -12
- package/package.json +2 -2
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
|
+
**739 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,
|
|
@@ -163,7 +163,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
163
163
|
|
|
164
164
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
165
165
|
|
|
166
|
-
### What the
|
|
166
|
+
### What the 739 tools cover
|
|
167
167
|
|
|
168
168
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
169
169
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/client.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
// Tiny fetch wrapper around the Hermoso HTTP API, shared by the MCP server (mcp/tools.mjs) and the CLI (bin/
|
|
1
|
+
// Tiny fetch wrapper around the Hermoso HTTP API, shared by the MCP server (mcp/tools.mjs) and the CLI (bin/heist.mjs).
|
|
2
2
|
// LOCAL today: no auth needed — the server's local auth adapter resolves the fixed dev account, so requireAuth/
|
|
3
|
-
// gateSpend pass. When real auth lands, set
|
|
4
|
-
// changes here. We attach the x-
|
|
3
|
+
// gateSpend pass. When real auth lands, set HEIST_TOKEN (a Bearer) and the SAME calls become authoritative — no
|
|
4
|
+
// changes here. We attach the x-heist-plan / x-heist-user fallbacks the browser also sends, purely for parity;
|
|
5
5
|
// the server treats them as non-authoritative (identity comes from the verified token / local dev user).
|
|
6
6
|
import { readFile } from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
@@ -11,12 +11,14 @@ import { AsyncLocalStorage } from 'node:async_hooks';
|
|
|
11
11
|
// makes carries THAT caller's bearer (bills their account). stdio keeps using the env token — ctx is simply unset.
|
|
12
12
|
export const mcpCtx = new AsyncLocalStorage();
|
|
13
13
|
|
|
14
|
-
export const API_BASE = (process.env.
|
|
15
|
-
const TOKEN = process.env.
|
|
16
|
-
// PINNED profile, or '' when
|
|
17
|
-
//
|
|
18
|
-
// masks the brand `use_brand` saved against the key
|
|
19
|
-
|
|
14
|
+
export const API_BASE = (process.env.HEIST_API_BASE || 'http://localhost:3000').replace(/\/+$/, '');
|
|
15
|
+
const TOKEN = process.env.HEIST_TOKEN || '';
|
|
16
|
+
// PINNED profile, or '' when the caller hasn't pinned one. This MUST stay unset by default: the server resolves
|
|
17
|
+
// an API key's profile as header > key.keyProfileId > 'default' (adapters/auth/middleware.js), so a client
|
|
18
|
+
// that ALWAYS sends the header permanently masks the brand `use_brand` saved against the key. Live 2026-07-27:
|
|
19
|
+
// use_brand reported "Now acting on Hermoso", and every connector tool still answered for the default brand —
|
|
20
|
+
// so Meta/Google Ads/YouTube/OneDrive all looked disconnected over MCP while being connected in the web app.
|
|
21
|
+
export const PROFILE = process.env.HEIST_PROFILE || '';
|
|
20
22
|
// SHARED TEAM WORKSPACE: the OWNING account. The web client sends this as x-hermoso-owner from PROFILE_OWNER
|
|
21
23
|
// (public/app.js ctxHeaders) whenever the active brand belongs to someone else's account; the MCP twins never did,
|
|
22
24
|
// so a member driving Hermoso headlessly resolved every brand-scoped read against their OWN empty account —
|
|
@@ -27,10 +29,10 @@ export const PROFILE = process.env.HERMOSO_PROFILE || '';
|
|
|
27
29
|
// default — sending an owner for your own account would make resolveWs take the shared branch against yourself.
|
|
28
30
|
// PAIR IT WITH THE PROFILE UUID, not the slug: profile_members keys on profiles.id, so a client_slug is the one
|
|
29
31
|
// thing isMember() cannot match and it 403s. list_brands names both values for every workspace you can enter.
|
|
30
|
-
export const OWNER = process.env.
|
|
32
|
+
export const OWNER = process.env.HEIST_OWNER || '';
|
|
31
33
|
// The env-var prefix THIS build reads. tools.mjs is byte-identical across the two twins, so it cannot
|
|
32
34
|
// hardcode either name when it tells a user which variables to set — it asks its own client.
|
|
33
|
-
export const ENV_PREFIX = '
|
|
35
|
+
export const ENV_PREFIX = 'HEIST';
|
|
34
36
|
|
|
35
37
|
// WHICH TOOL IS RUNNING. The error ledger groups on the OP, and a path alone cannot name the tool: `plan_ad`,
|
|
36
38
|
// `render_ad` and `make_template_ad` all fail through POST /api/create, so without this every MCP defect would be
|
|
@@ -42,14 +44,15 @@ export const toolCtx = new AsyncLocalStorage();
|
|
|
42
44
|
function headers(extra = {}) {
|
|
43
45
|
const ctx = mcpCtx.getStore();
|
|
44
46
|
// A HOSTED-CONNECTOR request (mcp/http.mjs) is a DIFFERENT TENANT from the process serving it, so its ctx is the
|
|
45
|
-
// ONLY scope it may carry: falling through to this process's
|
|
47
|
+
// ONLY scope it may carry: falling through to this process's HEIST_PROFILE / HEIST_OWNER would scope one
|
|
46
48
|
// customer's tool call to whatever workspace the SERVER's environment happens to name — a cross-tenant leak that
|
|
47
49
|
// is invisible because it succeeds. stdio/CLI keeps the env fallback: there the process and the caller are the
|
|
48
50
|
// same person. Presence of the ctx store IS "remote" (see isRemote below).
|
|
49
|
-
const prof = ctx ? (ctx.profile || '') : PROFILE; //
|
|
51
|
+
const prof = ctx ? (ctx.profile || '') : PROFILE; // omit entirely when unpinned so the key's saved brand wins server-side
|
|
50
52
|
const own = ctx ? (ctx.owner || '') : OWNER; // the wire name is x-hermoso-owner on BOTH twins — it is the server's header, not a brand
|
|
51
53
|
const tool = toolCtx.getStore()?.tool || '';
|
|
52
|
-
const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-
|
|
54
|
+
const h = { 'Content-Type': 'application/json', ...(prof ? { 'x-heist-user': prof } : {}), ...(own ? { 'x-hermoso-owner': own } : {}), ...(tool ? { 'x-hermoso-tool': tool } : {}), ...extra };
|
|
55
|
+
if (process.env.EDGE_SECRET) h['x-edge-auth'] = process.env.EDGE_SECRET; // belt: in-process self-calls satisfy the edge shield even if the loopback exemption ever changes
|
|
53
56
|
const tok = ctx?.token || TOKEN;
|
|
54
57
|
if (tok) h.Authorization = `Bearer ${tok}`;
|
|
55
58
|
return h;
|
|
@@ -99,12 +102,53 @@ export async function apiGet(p, query) {
|
|
|
99
102
|
// digit-strip reduced them to '' → GAQL "segments.date BETWEEN '' and ''". Drop empties before building the qs.
|
|
100
103
|
const clean = query && Object.fromEntries(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== ''));
|
|
101
104
|
const qs = clean && Object.keys(clean).length ? '?' + new URLSearchParams(clean).toString() : '';
|
|
102
|
-
const res = await
|
|
105
|
+
const res = await fetchRead(`${API_BASE}${p}${qs}`, { headers: headers() });
|
|
103
106
|
return unwrap(res);
|
|
104
107
|
}
|
|
105
108
|
|
|
109
|
+
// ── A TRANSPORT FAILURE IS NOT A DEFECT, AND IT MUST NOT READ LIKE ONE (2026-08-31) ────────────────────────────
|
|
110
|
+
// Every tool here reaches the app over HTTP, so a network blip between this process and the app surfaces as node's
|
|
111
|
+
// bare `TypeError: fetch failed` — no status, no message anyone can act on. Two of those paged us in one afternoon
|
|
112
|
+
// (`list_connectors` 2m after a deploy, `list_meta_posts` 1.6h into a settled revision), and the error ledger is
|
|
113
|
+
// right to classify a raw TypeError as ours: it cannot tell a genuine bug from a dropped connection.
|
|
114
|
+
//
|
|
115
|
+
// RETRYING A READ IS SAFE. RETRYING A WRITE IS NOT, and that asymmetry is the whole design: when a POST dies at the
|
|
116
|
+
// transport layer we do not know whether the server processed it, so a retry is how one scheduled post becomes two.
|
|
117
|
+
// That is the same rule publishOnce already enforces one layer up — a timeout is neither success nor failure — so
|
|
118
|
+
// GET (and only GET) gets one more attempt, and a write says plainly that it may or may not have landed.
|
|
119
|
+
// THE THREE UPLOAD/PUT PATHS BYPASSED ALL OF THIS UNTIL 2026-09-01, and a real user found it: `upload_file` over
|
|
120
|
+
// the hosted connector answered a bare `TypeError: fetch failed` (fp 3d7f69f6-1fc), which the ledger correctly
|
|
121
|
+
// files as OURS because a raw runtime error is indistinguishable from a genuine bug. They are WRITES, so they take
|
|
122
|
+
// `fetchWrite` — no retry (a repeated upload is a duplicate file) and the honest "it may or may not have landed"
|
|
123
|
+
// sentence instead of a stack-trace word the caller cannot act on.
|
|
124
|
+
const TRANSPORT_RE = /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|socket hang up|network|terminated/i;
|
|
125
|
+
const isTransport = (e) => !!e && e.name === 'TypeError' && TRANSPORT_RE.test(String(e.message || '') + ' ' + String(e.cause?.code || e.cause?.message || ''));
|
|
126
|
+
|
|
127
|
+
async function fetchRead(url, init) {
|
|
128
|
+
try { return await fetch(url, init); }
|
|
129
|
+
catch (e) {
|
|
130
|
+
if (!isTransport(e)) throw e;
|
|
131
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
132
|
+
try { return await fetch(url, init); }
|
|
133
|
+
catch (e2) {
|
|
134
|
+
if (!isTransport(e2)) throw e2;
|
|
135
|
+
throw Object.assign(new Error('Could not reach Hermoso just now — the request never got a response, so nothing was read. This is a connection problem, not a rejected request; try again.'), { _transport: true, _viaApi: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function fetchWrite(url, init) {
|
|
141
|
+
try { return await fetch(url, init); }
|
|
142
|
+
catch (e) {
|
|
143
|
+
if (!isTransport(e)) throw e;
|
|
144
|
+
// NOT RETRIED, DELIBERATELY. The request may already have been processed; sending it again is how a duplicate
|
|
145
|
+
// is made. The caller is told the honest thing — that the outcome is unknown — so it can CHECK rather than repeat.
|
|
146
|
+
throw Object.assign(new Error(`Could not reach Hermoso while sending that ${init?.method || 'request'} — the connection dropped before any answer came back, so it MAY OR MAY NOT have been applied. Check whether it took effect before sending it again; retrying blindly can duplicate it.`), { _transport: true, _viaApi: true });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
106
150
|
export async function apiPost(p, body = {}) {
|
|
107
|
-
const res = await
|
|
151
|
+
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: headers(), body: JSON.stringify(body) });
|
|
108
152
|
return unwrap(res);
|
|
109
153
|
}
|
|
110
154
|
|
|
@@ -112,17 +156,17 @@ export async function apiPost(p, body = {}) {
|
|
|
112
156
|
// "leave that field exactly as it was", so sending a whole object where a patch was meant would blank the fields
|
|
113
157
|
// the caller never mentioned. Only ever send the keys that are actually changing.
|
|
114
158
|
export async function apiPatch(p, body = {}) {
|
|
115
|
-
const res = await
|
|
159
|
+
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'PATCH', headers: headers(), body: JSON.stringify(body) });
|
|
116
160
|
return unwrap(res);
|
|
117
161
|
}
|
|
118
162
|
|
|
119
163
|
export async function apiDelete(p) {
|
|
120
|
-
const res = await
|
|
164
|
+
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'DELETE', headers: headers() });
|
|
121
165
|
return unwrap(res);
|
|
122
166
|
}
|
|
123
167
|
|
|
124
168
|
export async function apiPut(p, body = {}) {
|
|
125
|
-
const res = await
|
|
169
|
+
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'PUT', headers: headers(), body: JSON.stringify(body) });
|
|
126
170
|
return unwrap(res);
|
|
127
171
|
}
|
|
128
172
|
|
|
@@ -145,7 +189,7 @@ export const hostRendersWidgets = () => WIDGET_HOSTS.test(mcpCtx.getStore()?.cli
|
|
|
145
189
|
// ── WHICH WORKSPACE'S STORE KEYS THIS CALL WRITES ──────────────────────────────────────────────────────────────
|
|
146
190
|
// The suffix synced store keys carry (`` = the bare/anchor keys, `<clientSlug>` = a sub-brand). It comes from the
|
|
147
191
|
// SERVER (`GET /api/workspace` → resolveWs), never from this process's environment, because on the hosted twin the
|
|
148
|
-
// caller and the process are different tenants:
|
|
192
|
+
// caller and the process are different tenants: HEIST_PROFILE names whatever workspace the SERVER's env happens to
|
|
149
193
|
// mention, which for a hosted connector is nothing at all. That is how `use_brand "Client X"` kept reading and
|
|
150
194
|
// WRITING the anchor brand (live 2026-08-01), and how draft_brand's save overwrote the default brand's profile.
|
|
151
195
|
let _suffixMemo = null; // stdio/CLI only: one process = one caller, so a module-level memo is honest here
|
|
@@ -207,7 +251,7 @@ export async function connectedProviders() {
|
|
|
207
251
|
export async function apiUpload(p, buf, { contentType = 'application/octet-stream', fileName = '' } = {}) {
|
|
208
252
|
const h = headers({ 'Content-Type': contentType });
|
|
209
253
|
if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
|
|
210
|
-
const res = await
|
|
254
|
+
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: h, body: buf });
|
|
211
255
|
return unwrap(res);
|
|
212
256
|
}
|
|
213
257
|
// Ingest by URL: the SERVER fetches the bytes (SSRF-guarded on every redirect hop) so nothing has to cross this
|
|
@@ -216,13 +260,13 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
|
|
|
216
260
|
const h = headers({});
|
|
217
261
|
delete h['Content-Type']; // a body-less POST must not claim one; the server sniffs the FETCHED bytes
|
|
218
262
|
if (fileName) h['x-file-name'] = encodeURIComponent(fileName);
|
|
219
|
-
const res = await
|
|
263
|
+
const res = await fetchWrite(`${API_BASE}${p}?url=${encodeURIComponent(url)}`, { method: 'POST', headers: h });
|
|
220
264
|
return unwrap(res);
|
|
221
265
|
}
|
|
222
266
|
|
|
223
267
|
// /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
|
|
224
268
|
export async function apiSSE(p, body = {}) {
|
|
225
|
-
const res = await
|
|
269
|
+
const res = await fetchWrite(`${API_BASE}${p}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
|
|
226
270
|
if (!res.ok) { let e; try { e = (await res.json()).error; } catch {} throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
|
|
227
271
|
const reader = res.body.getReader(); const dec = new TextDecoder();
|
|
228
272
|
let buf = '', done = null, error = null; const progress = [];
|
|
@@ -260,7 +304,7 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
|
|
|
260
304
|
onTick?.(job);
|
|
261
305
|
if (job.status === 'done') return { job, result: jobResult(job) };
|
|
262
306
|
if (job.status === 'error') throw new Error(job.error || 'Render failed');
|
|
263
|
-
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `
|
|
307
|
+
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `heist jobs get ' + id + '`'), { jobId: id });
|
|
264
308
|
await new Promise(r => setTimeout(r, intervalMs));
|
|
265
309
|
}
|
|
266
310
|
}
|