hermoso 0.1.177 → 0.1.179
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 +14 -10
- package/mcp/hermoso-mcp.mjs +3 -3
- package/mcp/http.mjs +10 -10
- package/mcp/tools.mjs +76 -1
- 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
|
+
**745 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 745 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/hermoso.mjs).
|
|
2
2
|
// LOCAL today: no auth needed — the server's local auth adapter resolves the fixed dev account, so requireAuth/
|
|
3
|
-
// gateSpend pass.
|
|
4
|
-
// changes here. We attach the x-heist-plan / x-heist-user
|
|
3
|
+
// gateSpend pass. Set HERMOSO_TOKEN (a Bearer) and the SAME calls become authoritative — no
|
|
4
|
+
// changes here. We attach the x-heist-plan / x-heist-user headers (legacy wire names the server still reads) 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,14 +11,18 @@ 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
|
-
|
|
15
|
-
|
|
14
|
+
// ENV NAMES (2026-09-01): HERMOSO_* is the documented prefix (README, `claude mcp add … -e HERMOSO_TOKEN=…`); HEIST_* is the
|
|
15
|
+
// pre-rebrand name still honoured as a fallback. Measured before this fix: a stdio server started with ONLY HERMOSO_TOKEN and
|
|
16
|
+
// HERMOSO_API_BASE fell back to localhost:3000 and could not reach Hermoso — the documented setup did not authenticate.
|
|
17
|
+
// The published package defaults to the hosted API; a self-hoster sets HERMOSO_API_BASE.
|
|
18
|
+
export const API_BASE = ((process.env.HERMOSO_API_BASE ?? process.env.HEIST_API_BASE) || 'https://app.hermoso.ai').replace(/\/+$/, '');
|
|
19
|
+
const TOKEN = (process.env.HERMOSO_TOKEN ?? process.env.HEIST_TOKEN) || '';
|
|
16
20
|
// PINNED profile, or '' when the caller hasn't pinned one. This MUST stay unset by default: the server resolves
|
|
17
21
|
// an API key's profile as header > key.keyProfileId > 'default' (adapters/auth/middleware.js), so a client
|
|
18
22
|
// that ALWAYS sends the header permanently masks the brand `use_brand` saved against the key. Live 2026-07-27:
|
|
19
23
|
// use_brand reported "Now acting on Hermoso", and every connector tool still answered for the default brand —
|
|
20
24
|
// 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 || '';
|
|
25
|
+
export const PROFILE = (process.env.HERMOSO_PROFILE ?? process.env.HEIST_PROFILE) || '';
|
|
22
26
|
// SHARED TEAM WORKSPACE: the OWNING account. The web client sends this as x-hermoso-owner from PROFILE_OWNER
|
|
23
27
|
// (public/app.js ctxHeaders) whenever the active brand belongs to someone else's account; the MCP twins never did,
|
|
24
28
|
// so a member driving Hermoso headlessly resolved every brand-scoped read against their OWN empty account —
|
|
@@ -29,10 +33,10 @@ export const PROFILE = process.env.HEIST_PROFILE || '';
|
|
|
29
33
|
// default — sending an owner for your own account would make resolveWs take the shared branch against yourself.
|
|
30
34
|
// PAIR IT WITH THE PROFILE UUID, not the slug: profile_members keys on profiles.id, so a client_slug is the one
|
|
31
35
|
// thing isMember() cannot match and it 403s. list_brands names both values for every workspace you can enter.
|
|
32
|
-
export const OWNER = process.env.HEIST_OWNER || '';
|
|
36
|
+
export const OWNER = (process.env.HERMOSO_OWNER ?? process.env.HEIST_OWNER) || '';
|
|
33
37
|
// The env-var prefix THIS build reads. tools.mjs is byte-identical across the two twins, so it cannot
|
|
34
38
|
// hardcode either name when it tells a user which variables to set — it asks its own client.
|
|
35
|
-
export const ENV_PREFIX = '
|
|
39
|
+
export const ENV_PREFIX = 'HERMOSO';
|
|
36
40
|
|
|
37
41
|
// WHICH TOOL IS RUNNING. The error ledger groups on the OP, and a path alone cannot name the tool: `plan_ad`,
|
|
38
42
|
// `render_ad` and `make_template_ad` all fail through POST /api/create, so without this every MCP defect would be
|
|
@@ -44,7 +48,7 @@ export const toolCtx = new AsyncLocalStorage();
|
|
|
44
48
|
function headers(extra = {}) {
|
|
45
49
|
const ctx = mcpCtx.getStore();
|
|
46
50
|
// A HOSTED-CONNECTOR request (mcp/http.mjs) is a DIFFERENT TENANT from the process serving it, so its ctx is the
|
|
47
|
-
// ONLY scope it may carry: falling through to this process's
|
|
51
|
+
// ONLY scope it may carry: falling through to this process's HERMOSO_PROFILE / HERMOSO_OWNER would scope one
|
|
48
52
|
// customer's tool call to whatever workspace the SERVER's environment happens to name — a cross-tenant leak that
|
|
49
53
|
// is invisible because it succeeds. stdio/CLI keeps the env fallback: there the process and the caller are the
|
|
50
54
|
// same person. Presence of the ctx store IS "remote" (see isRemote below).
|
|
@@ -304,7 +308,7 @@ export async function pollJob(id, { intervalMs = 3000, timeoutMs = 10 * 60 * 100
|
|
|
304
308
|
onTick?.(job);
|
|
305
309
|
if (job.status === 'done') return { job, result: jobResult(job) };
|
|
306
310
|
if (job.status === 'error') throw new Error(job.error || 'Render failed');
|
|
307
|
-
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `
|
|
311
|
+
if (Date.now() > deadline) throw Object.assign(new Error('Render timed out — check `hermoso jobs get ' + id + '`'), { jobId: id });
|
|
308
312
|
await new Promise(r => setTimeout(r, intervalMs));
|
|
309
313
|
}
|
|
310
314
|
}
|
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
// Hermoso MCP server (stdio transport) — lets Claude Code / Cursor / Codex (and any stdio MCP client) drive Hermoso:
|
|
3
3
|
// research competitors, plan ads, and generate images/videos/avatars, all against the running Hermoso server.
|
|
4
4
|
//
|
|
5
|
-
// Local (today): node mcp/hermoso-mcp.mjs # talks to
|
|
6
|
-
// Auth (today): none — the local server resolves the dev account. Set
|
|
5
|
+
// Local (today): node mcp/hermoso-mcp.mjs # talks to https://app.hermoso.ai (HERMOSO_API_BASE to override)
|
|
6
|
+
// Auth (today): none — the local server resolves the dev account. Set HERMOSO_TOKEN (an agent key from the app).
|
|
7
7
|
//
|
|
8
8
|
// stdout is the JSON-RPC channel — NEVER print to it. All logging goes to stderr (console.error).
|
|
9
9
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
@@ -23,7 +23,7 @@ const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
|
|
|
23
23
|
// no reconnect. HERMOSO_TOOLS=all restores the full roster; HERMOSO_TOOLS=create,channels narrows it further.
|
|
24
24
|
// An unknown group EXITS rather than silently serving all of them — a scoped connection you did not get is
|
|
25
25
|
// worse than one you were told you could not have.
|
|
26
|
-
// Both env names are read: HERMOSO_TOOLS is the current prefix, HEIST_TOOLS the pre-rebrand
|
|
26
|
+
// Both env names are read: HERMOSO_TOOLS is the current prefix, HEIST_TOOLS the pre-rebrand name that is live in
|
|
27
27
|
// people's configs today. Renaming a variable someone already set is how a working setup goes quiet.
|
|
28
28
|
const _scope = parseToolScope(process.env.HERMOSO_TOOLS || process.env.HEIST_TOOLS);
|
|
29
29
|
if (_scope.error) { console.error(`[hermoso-mcp] ${_scope.error}`); process.exit(1); }
|
package/mcp/http.mjs
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// It is written so the cloud step is a CONFIG FLIP, not a rewrite — but it is intentionally OFF and will REFUSE
|
|
6
6
|
// to mount until BOTH are true:
|
|
7
|
-
// (1)
|
|
7
|
+
// (1) HERMOSO_MCP_REMOTE=1 (or the legacy HEIST_MCP_REMOTE), and
|
|
8
8
|
// (2) a real token verifier is wired (verifyBearer) — i.e. Firebase Auth (or equivalent) is configured.
|
|
9
9
|
// Why it must stay off locally: a public money-spending endpoint cannot exist without authenticated identity
|
|
10
10
|
// (the no-anon-spend rule), there is no hosted origin yet, and per the rollout plan cloud is provisioned
|
|
11
|
-
// COLLABORATIVELY, never solo. Until then, use the local stdio server (mcp/
|
|
11
|
+
// COLLABORATIVELY, never solo. Until then, use the local stdio server (mcp/hermoso-mcp.mjs) + the CLI + skills.
|
|
12
12
|
//
|
|
13
13
|
// When the cloud step happens, the remaining work is small and explicit (see ENABLE CHECKLIST at the bottom).
|
|
14
14
|
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -21,12 +21,12 @@ import { mcpCtx, connectedProviders } from './client.mjs';
|
|
|
21
21
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
22
22
|
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
23
23
|
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
24
|
-
if (process.env.HEIST_MCP_REMOTE !== '1') return false; // gate 1: off by default
|
|
24
|
+
if ((process.env.HERMOSO_MCP_REMOTE ?? process.env.HEIST_MCP_REMOTE) !== '1') return false; // gate 1: off by default
|
|
25
25
|
if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
|
|
26
26
|
console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
|
|
27
27
|
return false;
|
|
28
28
|
}
|
|
29
|
-
const BASE = (publicBaseUrl || process.env.HEIST_PUBLIC_URL || '').replace(/\/+$/, '');
|
|
29
|
+
const BASE = (publicBaseUrl || process.env.HERMOSO_PUBLIC_URL || process.env.HEIST_PUBLIC_URL || '').replace(/\/+$/, '');
|
|
30
30
|
|
|
31
31
|
// RFC 9728 protected-resource metadata — tells Claude.ai where to get a token. (Authorization-server metadata
|
|
32
32
|
// is served by the auth provider itself, e.g. Firebase/your IdP.) Scopes match the AS metadata + minted token
|
|
@@ -56,7 +56,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
56
56
|
const MCP_PATH = '/mcp'; // the resource's path component — app.all(MCP_PATH) below
|
|
57
57
|
const protectedResourceMetadata = (req, res) => res.json({
|
|
58
58
|
resource: `${BASE}${MCP_PATH}`,
|
|
59
|
-
authorization_servers: [process.env.HEIST_OAUTH_ISSUER].filter(Boolean),
|
|
59
|
+
authorization_servers: [process.env.HERMOSO_OAUTH_ISSUER || process.env.HEIST_OAUTH_ISSUER].filter(Boolean),
|
|
60
60
|
scopes_supported: ['hermoso.research', 'hermoso.generate'],
|
|
61
61
|
bearer_methods_supported: ['header'],
|
|
62
62
|
});
|
|
@@ -293,7 +293,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
293
293
|
}
|
|
294
294
|
// The caller's bearer rides into every /api call the tools make — spend bills THEIR account. `remote: true`
|
|
295
295
|
// says what this store IS: a per-request tenant scope on a shared, multi-tenant process. client.mjs treats the
|
|
296
|
-
// presence of this store as the signal to STOP falling back to the process's own
|
|
296
|
+
// presence of this store as the signal to STOP falling back to the process's own HERMOSO_PROFILE / HERMOSO_OWNER,
|
|
297
297
|
// which belong to whoever runs the box, not to whoever is calling.
|
|
298
298
|
//
|
|
299
299
|
// NOTE WHAT IS DELIBERATELY *NOT* HERE: a profile or an owner read off the request. There is nowhere honest to
|
|
@@ -304,14 +304,14 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
|
|
|
304
304
|
await mcpCtx.run({ token, remote: true, client: entry.client || '' }, () => entry.transport.handleRequest(req, res, req.body));
|
|
305
305
|
});
|
|
306
306
|
|
|
307
|
-
console.error(`[mcp-remote] mounted at ${BASE || '(set
|
|
307
|
+
console.error(`[mcp-remote] mounted at ${BASE || '(set HERMOSO_PUBLIC_URL)'}/mcp`);
|
|
308
308
|
return true;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
// ── ENABLE CHECKLIST (cloud step, collaborative) ──────────────────────────────────────────────────────────
|
|
312
|
-
// 1. Provision a hosted origin (Cloud Run) + Firebase Auth; set
|
|
312
|
+
// 1. Provision a hosted origin (Cloud Run) + Firebase Auth; set HERMOSO_PUBLIC_URL + HERMOSO_OAUTH_ISSUER.
|
|
313
313
|
// 2. Implement verifyBearer(token) via the Firebase auth adapter (adapters/auth/firebase.js) and pass it here.
|
|
314
314
|
// 3. Thread the authenticated user into mcp/client.mjs's outbound /api calls (AsyncLocalStorage) so reserve()/
|
|
315
315
|
// gateSpend bill the right account — the server-side enforcement is already authoritative once req.user is real.
|
|
316
|
-
// 4. Set
|
|
317
|
-
// 5. The published connector URL becomes `${
|
|
316
|
+
// 4. Set HERMOSO_MCP_REMOTE=1. Then in server.js: `import { mountRemoteMcp } from './mcp/http.mjs'; mountRemoteMcp(app, { verifyBearer, publicBaseUrl })`.
|
|
317
|
+
// 5. The published connector URL becomes `${HERMOSO_PUBLIC_URL}/mcp` — paste into Claude.ai → Settings → Connectors.
|
package/mcp/tools.mjs
CHANGED
|
@@ -10283,6 +10283,49 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10283
10283
|
outputSchema: { audienceId: z.string().optional(), name: z.string().optional(), membersSent: z.number().optional(), rejected: z.number().optional(), note: z.string().optional() },
|
|
10284
10284
|
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10285
10285
|
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience', a); return ok(d.note, d); }));
|
|
10286
|
+
server.registerTool('get_openai_ads_audience', {
|
|
10287
|
+
title: 'Read one ChatGPT Ads custom audience',
|
|
10288
|
+
description: 'Read one ChatGPT Ads custom audience: its processing status, how many users matched, what it can actually be USED for, and its membership revision. Read-only, free. STATUS is not the same question as eligibility — "ready" only means processing succeeded. EXCLUSION (suppression) has NO minimum size, but INCLUSION and bid multipliers need roughly 25,000 matched users, so a small list used for inclusion simply never serves. The membershipRevision this returns is what update_openai_ads_audience_members REQUIRES for a replace.',
|
|
10289
|
+
inputSchema: { audienceId: z.string() },
|
|
10290
|
+
outputSchema: { audience: z.any().optional(), note: z.string().optional() },
|
|
10291
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10292
|
+
}, wrap(async (a) => { const d = await apiGet('/api/openai-ads/audience', a); return ok(d.note, d); }));
|
|
10293
|
+
server.registerTool('update_openai_ads_audience_members', {
|
|
10294
|
+
title: 'Add, remove or replace ChatGPT Ads audience members',
|
|
10295
|
+
description: 'Change who is IN a ChatGPT Ads custom audience. Pass plain emails and/or phone numbers: Hermoso normalises and SHA-256 hashes them locally and only the digests are sent. THREE OPERATIONS: "add" puts people in, "remove" takes the named people OUT (the only way to stop advertising to a segment already in a list), "replace" swaps the WHOLE membership. THIS IS ASYNCHRONOUS — ChatGPT Ads returns an operation id and the change is NOT applied when this returns; poll it with get_openai_ads_audience_operation until it reports succeeded or failed. A replace REQUIRES expectedRevision (read membershipRevision from get_openai_ads_audience) so a wholesale swap cannot land on top of someone else’s change; a mismatch is refused by ChatGPT Ads and applies nothing.',
|
|
10296
|
+
inputSchema: {
|
|
10297
|
+
audienceId: z.string(),
|
|
10298
|
+
operation: z.enum(['add', 'remove', 'replace']).describe('add | remove | replace — replace swaps the entire membership'),
|
|
10299
|
+
members: z.array(z.string()).describe('emails and/or phone numbers (already-SHA256-hashed emails pass through as-is)'),
|
|
10300
|
+
expectedRevision: z.union([z.number(), z.string()]).optional().describe('REQUIRED for replace, optional for add/remove — membershipRevision from get_openai_ads_audience'),
|
|
10301
|
+
},
|
|
10302
|
+
outputSchema: { operationId: z.string().optional(), audienceId: z.string().optional(), operation: z.string().optional(), status: z.string().optional(), membersSent: z.number().optional(), rejected: z.number().optional(), note: z.string().optional() },
|
|
10303
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10304
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/members', a); return ok(d.note, d); }));
|
|
10305
|
+
server.registerTool('get_openai_ads_audience_operation', {
|
|
10306
|
+
title: 'Check a ChatGPT Ads audience membership operation',
|
|
10307
|
+
description: 'Poll an add/remove/replace submitted by update_openai_ads_audience_members until it reports succeeded or failed. Read-only, free. Until it succeeds the membership has NOT changed, so never report an audience update as done on the strength of the submission alone.',
|
|
10308
|
+
inputSchema: { audienceId: z.string(), operationId: z.string() },
|
|
10309
|
+
outputSchema: { operationId: z.string().optional(), audienceId: z.string().optional(), operation: z.string().optional(), status: z.string().optional(), note: z.string().optional() },
|
|
10310
|
+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
10311
|
+
}, wrap(async (a) => { const d = await apiGet('/api/openai-ads/audience/operation', a); return ok(d.note, d); }));
|
|
10312
|
+
server.registerTool('merge_openai_ads_audiences', {
|
|
10313
|
+
title: 'Merge ChatGPT Ads custom audiences',
|
|
10314
|
+
description: 'Combine 2 to 64 existing ChatGPT Ads custom audiences into ONE new audience. The SOURCE audiences are left unchanged, later updates to them do NOT propagate into the merged one, and no existing campaign switches to the new id by itself — re-point targeting deliberately if that is the intent. Creates a new audience; changes nothing that is already serving.',
|
|
10315
|
+
inputSchema: {
|
|
10316
|
+
name: z.string().describe('name for the new merged audience, at least 3 characters'),
|
|
10317
|
+
audienceIds: z.array(z.string()).describe('2 to 64 DISTINCT audience ids from list_openai_ads_audiences'),
|
|
10318
|
+
},
|
|
10319
|
+
outputSchema: { audienceId: z.string().optional(), name: z.string().optional(), mergedFrom: z.array(z.string()).optional(), note: z.string().optional() },
|
|
10320
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
|
|
10321
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/merge', a); return ok(d.note, d); }));
|
|
10322
|
+
server.registerTool('archive_openai_ads_audience', {
|
|
10323
|
+
title: 'Archive a ChatGPT Ads custom audience (permanent)',
|
|
10324
|
+
description: 'Retire a ChatGPT Ads custom audience. ARCHIVING IS PERMANENT AND THERE IS NO DELETE TO UNDO IT: an archived audience can never be restored, targeted or bid on again, and any campaign that includes or excludes it loses that audience. Without confirm:true this archives NOTHING and instead reports the audience’s real name, status and matched size read back from ChatGPT Ads, so the cost is visible before it is paid. Pass confirm:true only once that is what the user wants.',
|
|
10325
|
+
inputSchema: { audienceId: z.string(), confirm: z.boolean().optional().describe('must be true to actually archive — this cannot be undone') },
|
|
10326
|
+
outputSchema: { audienceId: z.string().optional(), audience: z.any().optional(), archived: z.boolean().optional(), alreadyArchived: z.boolean().optional(), note: z.string().optional() },
|
|
10327
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
10328
|
+
}, wrap(async (a) => { const d = await apiPost('/api/openai-ads/audience/archive', a); return ok(d.note, d); }));
|
|
10286
10329
|
server.registerTool('create_openai_ads_campaign', {
|
|
10287
10330
|
title: 'Build a ChatGPT Ads campaign (paused)',
|
|
10288
10331
|
description: 'Build a campaign on the connected ChatGPT Ads account — the ads that appear below ChatGPT answers. ALWAYS created PAUSED at every level, with no override: it spends NOTHING until you activate it with set_openai_ads_status(confirm:true). The object graph is campaign → ad group → ad, and a campaign ON ITS OWN CANNOT SERVE AN IMPRESSION, so pass adGroup{name, maxBid, contextHints, ad{creative}} and this builds the whole tree. THE CREATIVE IS A TEXT + IMAGE CARD AND NOTHING ELSE — title 3–50 characters, body 100 maximum, one landing page, one still image. THERE IS NO VIDEO ON THIS CHANNEL: never offer a video ad here, and if the brand only has video, pull a frame from it first. TARGETING IS SEMANTIC: context hints are natural-language descriptions of the conversations where this ad belongs (up to 2,000 per ad group). Geo (countries / locationIds) and PLATFORMS (which of the iOS app, Android app and web the ad runs on) are the only other dimensions — leave platforms out to run on all three. They guide matching, they are NOT exact-match keywords, and they do not guarantee delivery. OpenAI’s own guidance is BREADTH — many genuinely distinct hints and many distinct title/body angles beat one message repeated — which is exactly what plan_variations and mine_angles produce. OpenAI has no atomic multi-object write available here, so the whole tree is VALIDATED before the first write; if a level below the campaign is still rejected, the campaign is left PAUSED (spending nothing) and the note says exactly what exists — nothing is archived behind your back, because archiving is irreversible. Everything is READ BACK from OpenAI before you are told it exists: print the returned note verbatim, and if it says the campaign cannot serve yet, say that rather than calling it a finished ad.',
|
|
@@ -14759,7 +14802,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
14759
14802
|
durationSeconds: z.number().optional().describe('total ad length in seconds (supported range 4–180; outside that it is clamped). Omit to honor the plan’s own duration — that is almost always right. This only RE-TIMES an already-authored board (its scenes are scaled to fit), it does NOT re-write it, so to change the length of the ad the user asked for, re-run plan_ad with durationSeconds instead. A length that fits ONE clip of the render model renders as one continuous pass; longer is stitched from acts filled to that model’s clip maximum with the remainder last — the maximum is 15s on most models and 30s on the longest-clip one, so use dryRun:true to see the exact act split for free before spending.'),
|
|
14760
14803
|
aspectRatio: z.string().optional().describe('output aspect ratio, e.g. 9:16 (default) / 1:1 / 16:9'),
|
|
14761
14804
|
resolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe("'1080p' default (what we ship and bill for); '480p'/'720p' = cheaper draft passes, '4k' = premium final delivery (more credits). NOT EVERY MODEL OFFERS EVERY TIER — this enum is what the tool accepts, and each model's OWN `resolutions` list in hermoso_capabilities is what it can actually render (the longest-clip 30s model, for one, tops out at 720p). Ask for a tier the chosen model does not list and it is rendered at that model's best available tier instead, with nothing in the reply saying so — so check `resolutions` before promising anyone 1080p or 4k."),
|
|
14762
|
-
captions: z.boolean().optional().describe('
|
|
14805
|
+
captions: z.boolean().optional().describe('burn the plan\'s per-scene on-screen words as caption pills. DEFAULT FALSE — leave it off unless the user asks for on-screen text (no captions, or true subtitles of what is said; never scene or emphasis labels); a recipe whose format IS on-screen text keeps its text either way'),
|
|
14763
14806
|
endCard: z.boolean().optional().describe('branded end card on/off (default: on, except organic recipes)'),
|
|
14764
14807
|
music: z.boolean().optional().describe('licensed music bed on/off (default on)'),
|
|
14765
14808
|
lockup: z.boolean().optional().describe('persistent brand-logo lockup overlay on/off'),
|
|
@@ -16824,6 +16867,38 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
16824
16867
|
return okVideo(`Edited clip: ${r.url}`, r);
|
|
16825
16868
|
}));
|
|
16826
16869
|
|
|
16870
|
+
// AD MULTIPLIER (2026-09-01): ONE winning ad → N variants (new character / outfit / location / objects) with the edit, the
|
|
16871
|
+
// motion and the ORIGINAL AUDIO untouched. The plan is one server call; every variant is an ordinary videoedit job.
|
|
16872
|
+
server.registerTool('multiply_ad', {
|
|
16873
|
+
title: 'Multiply an ad',
|
|
16874
|
+
description: "MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were (that is what made the ad work), and any burned-in captions are removed. Pass the source video URL (a previous render, a job result, list_library, or the top performer from post_performance / meta_insights). The edit model takes sources up to ~10 seconds — a longer video is refused for free with the way out (clip_video it first). Returns the plan and ONE JOB PER VARIANT — call get_job on each until it reports done; do not describe a variant before its URL arrives. Cost is quoted per variant in the reply (use dryRun:true to see the plan and the quote without rendering). Regions: pass regions:['Berlin','Tokyo'] to restyle variants per market; translation is a separate, explicit step — dub_video on a finished variant.",
|
|
16875
|
+
inputSchema: {
|
|
16876
|
+
video: z.string().describe('the source video URL'),
|
|
16877
|
+
count: z.number().optional().describe('how many variants, 1-12 (default 6)'),
|
|
16878
|
+
axes: z.array(z.enum(['character', 'outfit', 'location', 'objects'])).optional().describe('which axes to vary (default: all four)'),
|
|
16879
|
+
notes: z.string().optional().describe('anything the variants must respect, e.g. "keep it women 25-40", "no gyms"'),
|
|
16880
|
+
regions: z.array(z.string()).optional().describe('markets to restyle for, one or more variants each, e.g. ["Berlin","Tokyo","São Paulo"] — visuals only; audio is never translated here'),
|
|
16881
|
+
dryRun: z.boolean().optional().describe('true = return the plan and the quote, render nothing'),
|
|
16882
|
+
},
|
|
16883
|
+
outputSchema: { jobs: z.array(z.any()).optional(), plan: z.any().optional(), perVariantCredits: z.number().optional(), totalCredits: z.number().optional(), dryRun: z.boolean().optional() },
|
|
16884
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
16885
|
+
}, wrap(async ({ video, count, axes, notes, regions, dryRun }) => {
|
|
16886
|
+
const src = String(video || '').trim();
|
|
16887
|
+
if (!/^https?:\/\//.test(src)) return { content: [{ type: 'text', text: 'Pass the source video as a URL — a previous render, a job result, or an entry from list_library.' }], isError: true };
|
|
16888
|
+
const n = Math.max(1, Math.min(12, Math.round(+count) || 6));
|
|
16889
|
+
const plan = await apiPost('/api/multiply/plan', { video: src, count: n, ...(axes ? { axes } : {}), ...(notes ? { notes } : {}), ...(regions ? { regions } : {}) });
|
|
16890
|
+
const p = plan?.data || plan;
|
|
16891
|
+
const lines = (p.variants || []).map((v, i) => `${i + 1}. ${v.label} — ${['character', 'outfit', 'location', 'objects'].filter(a => v[a] && v[a] !== 'same').map(a => a + ': ' + v[a]).join('; ')}`);
|
|
16892
|
+
const quote = `~${p.perVariantCredits} credits per variant · ~${p.totalCredits} for ${(p.variants || []).length}`;
|
|
16893
|
+
if (dryRun) return { content: [{ type: 'text', text: `Plan (nothing rendered). Source: ${Object.entries(p.source || {}).map(([k, v]) => k + ': ' + v).join('; ')}\n${lines.join('\n')}\n${quote}. Run again without dryRun to render.` }], structuredContent: { plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits, dryRun: true } };
|
|
16894
|
+
const jobs = [];
|
|
16895
|
+
for (const v of (p.variants || [])) {
|
|
16896
|
+
const job = await submitJob('videoedit', { video: src, prompt: v.instruction, keepAudio: true }, { label: `Multiply · ${v.label}` });
|
|
16897
|
+
jobs.push({ id: job.id, label: v.label });
|
|
16898
|
+
}
|
|
16899
|
+
return { content: [{ type: 'text', text: `Multiplying — ${jobs.length} variant(s) queued, original audio kept on all of them. ${quote}.\n${jobs.map((j, i) => `${i + 1}. ${j.label} → job ${j.id}`).join('\n')}\nEach is a normal video edit (1-4 minutes). Call get_job with each id until it reports done; a variant has NO file until then.` }], structuredContent: { jobs, plan: p, perVariantCredits: p.perVariantCredits, totalCredits: p.totalCredits } };
|
|
16900
|
+
}));
|
|
16901
|
+
|
|
16827
16902
|
server.registerTool('dub_video', {
|
|
16828
16903
|
title: 'Dub video',
|
|
16829
16904
|
description: "Localize a finished video into another language WITHOUT re-rendering it: the spoken track is transcribed, translated, re-voiced and lip-synced back onto the SAME footage, so the visuals, timing and edit are untouched. Just pass the video and the language — the script is read off the source automatically (pass `script` only to override what it heard). Paid; returns the served URL of the localized video.",
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.179",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio and marketing MCP server with
|
|
5
|
+
"description": "AI ad studio and marketing MCP server with 745 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|