hermoso 0.1.4 → 0.1.6

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 CHANGED
@@ -8,18 +8,17 @@ image & video ads (your real product composited in, copy + CTA included) — all
8
8
  ## Instant: the hosted Claude.ai connector
9
9
 
10
10
  Paste **`https://app.hermoso.ai/mcp`** into Claude → Settings → Connectors → *Add custom connector*, approve with
11
- your Hermoso account, done — all 22 tools with your saved brand context, billed to your plan.
11
+ your Hermoso account, done — the full studio toolset with your saved brand context, billed to your plan.
12
12
 
13
13
  ## Quickstart for Claude Code / Cursor / scripts (2 minutes)
14
14
 
15
15
  1. **Get an account** at [app.hermoso.ai](https://app.hermoso.ai) — free tier included; plans & credits are the
16
16
  same ones the web Studio uses.
17
17
  2. **Create an agent key**: app.hermoso.ai → **Settings → Agents & API** → Create API key (`hmk_…`).
18
- 3. **Connect** (Claude Code shown; any MCP client works):
18
+ 3. **Connect** — no clone needed, `npx` runs the published `hermoso` package (Claude Code shown; any MCP client works):
19
19
 
20
20
  ```bash
21
- git clone https://github.com/hermoso-ai/hermoso.git && cd hermoso && npm install
22
- claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- node "$(pwd)/mcp/hermoso-mcp.mjs"
21
+ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
23
22
  ```
24
23
 
25
24
  Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
@@ -28,28 +27,33 @@ learned memory you set up in the web app apply automatically (`get_brand` shows
28
27
 
29
28
  ## 1. MCP server (stdio) — Claude Code / Cursor / Codex
30
29
 
31
- `mcp/hermoso-mcp.mjs` is a stdio MCP server exposing 22 tools.
30
+ `hermoso mcp` runs a stdio MCP server exposing the full studio toolset (40+ tools). The published `hermoso`
31
+ package means no clone — `npx -y hermoso mcp` fetches and runs it:
32
32
 
33
33
  ```bash
34
- npm install
35
- claude mcp add hermoso -- node "$(pwd)/mcp/hermoso-mcp.mjs"
34
+ claude mcp add hermoso -e HERMOSO_TOKEN=hmk_… -- npx -y hermoso mcp
36
35
  ```
37
36
 
38
37
  Cursor / Codex — add to `mcp.json` (Codex uses the TOML equivalent):
39
38
 
40
39
  ```json
41
- { "mcpServers": { "hermoso": { "command": "node", "args": ["<repo>/mcp/hermoso-mcp.mjs"],
40
+ { "mcpServers": { "hermoso": { "command": "npx", "args": ["-y", "hermoso", "mcp"],
42
41
  "env": { "HERMOSO_API_BASE": "https://app.hermoso.ai", "HERMOSO_TOKEN": "<your token>" } } } }
43
42
  ```
44
43
 
45
44
  Then ask your agent: *“Generate an image ad with Hermoso.”*
46
45
 
47
- **Tools (22):** `hermoso_capabilities`, `hermoso_credits`, `get_brand`, `plan_ad`, `plan_variations`, `generate_image`,
48
- `generate_video`, `generate_avatar`, `stitch_video`, `reframe_video`, `upscale_video`, `dub_video`,
49
- `recast_motion`, `analyze_video`, `score_ad`, `get_job`, `list_jobs`, `find_competitors`,
50
- `pull_competitor_ads`, `research_ads`, `draft_brand`, `fetch_asset`. Call `hermoso_capabilities` first — it
51
- returns valid model ids and per-render credit costs. Render jobs queue server-side and poll to completion,
52
- returning a served URL.
46
+ **Tools (40+):** research/ad-spy (`find_competitors`, `pull_competitor_ads`, `research_ads`, `search_meta_ads`,
47
+ `search_google_ads`, `search_linkedin_ads`, `search_tiktok`, `search_instagram`, `search_youtube`, `search_reddit`,
48
+ `search_threads`, `scrapecreators_fetch`), plan → generate → finish (`plan_ad`, `plan_variations`, `generate_image`,
49
+ `generate_video`, `generate_avatar`, `render_ad`, `make_template_ad`, `stitch_video`, `reframe_video`,
50
+ `upscale_video`, `dub_video`, `change_voice`, `recast_motion`, `remix_static`, `finish_video`, `fix_beat`),
51
+ brand + account (`get_brand`, `list_brands`, `use_brand`, `draft_brand`, `list_product_photos`, `set_product_image`,
52
+ `hermoso_capabilities`, `hermoso_credits`, `buy_credits`), and analysis/jobs (`analyze_video`, `score_ad`,
53
+ `check_ad_policy`, `competitor_teardown`, `mine_angles`, `get_job`, `list_jobs`, `get_skill`, `list_skills`,
54
+ `fetch_asset`). Call `hermoso_capabilities` first — it returns valid model ids and per-render credit costs;
55
+ `tools/list` is the authoritative current set. Render jobs queue server-side and poll to completion, returning a
56
+ served URL.
53
57
 
54
58
  ## 2. CLI — the token-cheap path for terminal agents
55
59
 
package/bin/hermoso.mjs CHANGED
@@ -70,6 +70,12 @@ async function main() {
70
70
  process.env.HERMOSO_API_BASE = process.env.HERMOSO_API_BASE || cfg.apiBase || 'https://app.hermoso.ai';
71
71
  if (cfg.token && !process.env.HERMOSO_TOKEN) process.env.HERMOSO_TOKEN = cfg.token;
72
72
  if (cfg.profile && !process.env.HERMOSO_PROFILE) process.env.HERMOSO_PROFILE = cfg.profile;
73
+
74
+ // `hermoso mcp` → run the stdio MCP server (Claude Code / Cursor / Codex spawn this, e.g. `npx -y hermoso mcp`).
75
+ // It OWNS stdout as the JSON-RPC channel, so hand off immediately and print nothing to stdout here. The
76
+ // config-resolved API base + token (set just above) ride into the server's client via env; all logs go to stderr.
77
+ if (group === 'mcp') { await import('../mcp/hermoso-mcp.mjs'); return; }
78
+
73
79
  const api = await import('../mcp/client.mjs');
74
80
  const out = (label, data) => { if (flags.json) console.log(JSON.stringify(data, null, 2)); else console.log(label); };
75
81
  const absUrl = (u) => (u && u.startsWith('/') ? api.API_BASE + u : u);
@@ -149,7 +155,8 @@ async function main() {
149
155
  generate image --prompt [--ref] [--model] [--aspect] generate video|avatar|stitch … [--wait]
150
156
  jobs list | jobs get <id> [--wait] competitors <domain>
151
157
  ads pull (--company|--domain) research "<request>"
152
- fetch <url> [--out] version
158
+ fetch <url> [--out] mcp (run the stdio MCP server)
159
+ version
153
160
  add --json to any command for machine output.`);
154
161
  }
155
162
  } catch (e) { die(e?.message || String(e)); }
@@ -8,11 +8,13 @@
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';
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
- import { registerTools } from './tools.mjs';
11
+ import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
12
12
  import { API_BASE } from './client.mjs';
13
13
 
14
+ // instructions = the full capability map (ad spy · create · raw model playground · account) — one source of truth
15
+ // in tools.mjs, shared with the hosted connector (http.mjs), so every surface tells agents the same breadth.
14
16
  const server = new McpServer({ name: 'hermoso-mcp', version: '1.0.0' }, {
15
- instructions: 'Hermoso generates copyable, on-brand ad creative. Typical flow: hermoso_capabilities (learn valid model ids + costs) → optionally draft_brand → plan_ad (concept + copy) → generate_image / generate_video (returns a served URL). Use find_competitors / pull_competitor_ads / research_ads to gather proven ads to remix first. Structured ad-spy when you know exactly what to pull: search_meta_ads / search_google_ads / search_linkedin_ads (ad libraries), search_tiktok / search_instagram / search_youtube / search_reddit / search_threads (organic), scrapecreators_fetch (any allowlisted endpoint). Always report the final media URL to the user.',
17
+ instructions: MCP_INSTRUCTIONS,
16
18
  });
17
19
 
18
20
  registerTools(server);
package/mcp/http.mjs CHANGED
@@ -15,7 +15,7 @@
15
15
  // ───────────────────────────────────────────────────────────────────────────────────────────────────────
16
16
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
17
17
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
18
- import { registerTools } from './tools.mjs';
18
+ import { registerTools, MCP_INSTRUCTIONS } from './tools.mjs';
19
19
  import { mcpCtx } from './client.mjs';
20
20
 
21
21
  // Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
@@ -51,7 +51,7 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl } = {}) {
51
51
  const sid = req.headers['mcp-session-id'];
52
52
  let entry = sid && sessions.get(sid);
53
53
  if (!entry) {
54
- const server = new McpServer({ name: 'hermoso', version: '1.0.0' });
54
+ const server = new McpServer({ name: 'hermoso', version: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
55
55
  registerTools(server); // the SAME tools as stdio — but here every /api call they make carries this user's token
56
56
  const transport = new StreamableHTTPServerTransport({
57
57
  sessionIdGenerator: () => 'sess_' + Math.random().toString(36).slice(2),
package/mcp/tools.mjs CHANGED
@@ -14,6 +14,30 @@ const ok = (text, data) => ({ content: [{ type: 'text', text }], structuredConte
14
14
  const stillMsg = (r) => `Still rendering — job ${r.jobId}. This is NORMAL: video renders take 1–3 minutes and each get_job call waits up to ~45s, so it can take several calls. Keep calling get_job with this id until status is done or error — do NOT ask the user whether to keep waiting, and do NOT re-fire the render on another model (that double-charges). Only surface a problem after ~6 minutes of polling.`;
15
15
  const okVideo = async (text, r) => {
16
16
  if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; return { content: [{ type: 'text', text: p ? text + '\n(first frame attached — open the URL for the full video)' : text }, ...(p ? [p] : [])], structuredContent: r ?? undefined }; };
17
+
18
+ // ── CAPABILITY MAP — the FULL agent surface, four categories. Appended to hermoso_capabilities so an agent that
19
+ // probes once learns everything Hermoso does (not just the models): ad spy, create, raw playground, account. Keep
20
+ // crisp + tool-named so the model can act on it directly. (Server-level orientation lives in MCP_INSTRUCTIONS below.)
21
+ const CAPABILITY_MAP = [
22
+ 'What Hermoso can do — the full agent surface (every tool below runs over this MCP):',
23
+ 'A) AD SPY / RESEARCH — spy on the ads already winning in any market, then mine them. find_competitors · competitor_teardown · pull_competitor_ads · research_ads (open brief) · ad libraries search_meta_ads / search_google_ads / search_linkedin_ads · organic social search_tiktok / search_instagram / search_youtube / search_reddit / search_threads · scrapecreators_fetch (any allowlisted endpoint) · mine_angles · analyze_video · check_ad_policy · list_skills / get_skill (teardowns + creative playbooks).',
24
+ 'B) CREATE — finished, on-brand image & video ads (real product composited in, copy + CTA baked). draft_brand / get_brand / use_brand · plan_ad (concept + copy) → render_ad (the Studio quality pipeline) or generate_image / generate_video / generate_avatar (UGC creators + lip-sync) · make_template_ad (native HTML ad formats) · remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video · plan_variations + score_ad (fan out + rank).',
25
+ 'C) RAW MODEL PLAYGROUND — direct access to the full catalog (30+ image / video / voice / writing models, each with the exact per-render credit cost shown above), no ad framing: generate_image / generate_video (useBrand:false) for plain prompt-only renders, generate_voice for raw text-to-speech against any voice engine, and generate_text for the writing models (Claude / Gemini / GPT / Llama / DeepSeek…) — all against ANY catalog id.',
26
+ 'D) ACCOUNT — hermoso_credits (balance) · billing_status (plan + your billing role) · buy_credits (top-up checkout link) · upgrade_plan / set_auto_reload (admin) · list_jobs / get_job (track async renders).',
27
+ ].join('\n');
28
+
29
+ // Server-level `instructions` (initialize response — injected into the model's context by the client). Denser than
30
+ // the capability map: it names the three jobs + the same four categories so a freshly-connected agent immediately
31
+ // knows the breadth. Exported so BOTH the stdio server (hermoso-mcp.mjs) and the hosted connector (http.mjs) share one
32
+ // source of truth. Kept parity across mcp/ and cli/mcp/ (the npm copy).
33
+ export const MCP_INSTRUCTIONS = [
34
+ 'Hermoso is an AI ad studio you drive over MCP — use it for three jobs: (1) AD SPY / research the ads already winning in any market, (2) CREATE finished on-brand image & video ads, and (3) run RAW generations against the full model catalog. Call hermoso_capabilities FIRST (free) to learn valid model ids + exact credit costs. Capability map:',
35
+ '• AD SPY / RESEARCH: find_competitors, competitor_teardown, pull_competitor_ads, research_ads; ad libraries search_meta_ads / search_google_ads / search_linkedin_ads; organic search_tiktok / search_instagram / search_youtube / search_reddit / search_threads; scrapecreators_fetch; mine_angles; analyze_video; check_ad_policy; list_skills / get_skill.',
36
+ '• CREATE (finished ads): draft_brand → plan_ad → render_ad (Studio quality pipeline) or generate_image / generate_video / generate_avatar; make_template_ad (native HTML formats); remix_static / recast_motion / reframe_video / upscale_video / dub_video / change_voice / finish_video / fix_beat / stitch_video; plan_variations + score_ad.',
37
+ '• RAW MODEL PLAYGROUND: generate_image / generate_video (useBrand:false) for prompt-only renders, generate_voice for text-to-speech, generate_text for the writing models — against any of 30+ image / video / voice / writing model ids (exact costs in hermoso_capabilities), no ad framing.',
38
+ '• ACCOUNT: hermoso_credits, billing_status, buy_credits (top-up link), upgrade_plan / set_auto_reload (admin), list_jobs / get_job.',
39
+ 'No anonymous spend — tools/call needs a bearer. Out of credits → buy_credits mints a Stripe link your human pays; agents never spend money directly. Always report the final media URL to the user.',
40
+ ].join('\n');
17
41
  // Inline the finished image so Claude RENDERS it in chat instead of just linking it (MCP image content block).
18
42
  // Skipped silently for huge files / fetch errors — the URL in the text always works.
19
43
  // Claude can't play video inline — attach the FIRST FRAME as an image block next to the link so the spot is
@@ -41,7 +65,7 @@ const wrap = (fn) => async (args, extra) => {
41
65
  catch (e) {
42
66
  let msg = `Error: ${e?.message || e}`;
43
67
  // credit outages need an actionable path the agent can relay — the web app has a top-up gate; here the URL is it
44
- if (/not enough credits/i.test(msg)) msg += `\nTop up or upgrade at https://app.hermoso.ai (Settings Billing), then retry — nothing was charged. hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
68
+ if (/not enough credits/i.test(msg)) msg += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs; your human pays on Stripe's secure page — nothing was charged here). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
45
69
  return { content: [{ type: 'text', text: msg }], isError: true };
46
70
  }
47
71
  };
@@ -65,19 +89,24 @@ async function renderJob(type, input, label) {
65
89
  export function registerTools(server) {
66
90
  // ---------- read-only / discovery ----------
67
91
  server.registerTool('hermoso_capabilities', {
92
+ title: 'Hermoso capabilities',
68
93
  description: 'Probe what this Hermoso account can do RIGHT NOW: available image/video model ids + their exact credit costs, aspect ratios, video durations, the recipe ids, and the canEdit/canAvatar/canPublish flags. Call this FIRST so you generate with valid model ids and known costs. Read-only, free.',
69
94
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
70
95
  }, wrap(async () => {
71
96
  const d = await apiGet('/api/generate/status');
72
- const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.best ? ', best' : ''})`).join('; ');
97
+ const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
73
98
  // durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
74
99
  // prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
75
- const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.best ? ', best' : ''})`).join('; ');
76
- const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\nIMPORTANT: durations above are SINGLE-PASS e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation (do NOT assume a generic 8–10s cap, and do NOT stitch for ≤15s spots; stitching is only for longer). durationSeconds must be one of the model's listed values.\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…`;
100
+ const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${m.best ? ', best' : ''})`).join('; ');
101
+ // voice engines (generate_voice) + writing models (generate_text)so the RAW PLAYGROUND is usable from one probe
102
+ const voice = d.options?.voice ? (d.options.voice.engines || []).map(e => `${e.id} (${e.label}: ${(e.voices || []).slice(0, 6).join('/')}${(e.voices || []).length > 6 ? '…' : ''}, ${e.creditsPer1k}cr/1k chars)`).join('; ') : 'unavailable';
103
+ const llm = d.options?.llm ? (d.options.llm.models || []).map(m => `${m.id} (${m.label})`).join('; ') : 'unavailable';
104
+ const text = `Image: ${d.image ? img : 'unavailable'}\nVideo: ${d.video ? vid : 'unavailable'}\nIMPORTANT: durations above are SINGLE-PASS — e.g. seedance-2 renders a full multi-beat 15s ad in ONE generation (do NOT assume a generic 8–10s cap, and do NOT stitch for ≤15s spots; stitching is only for longer). durationSeconds must be one of the model's listed values.\nVoice engines (generate_voice): ${voice}\nWriting models (generate_text): ${llm}\ncanEdit:${d.canEdit} canAvatar:${d.canAvatar} canPublish:${d.canPublish}\nRecipes (${(d.recipes || []).length}): ${(d.recipes || []).slice(0, 20).map(r => r.id).join(', ')}…\n\n${CAPABILITY_MAP}`;
77
105
  return ok(text, d);
78
106
  }));
79
107
 
80
108
  server.registerTool('hermoso_credits', {
109
+ title: 'Credit balance',
81
110
  description: 'Return the account credit balance, credits used this session, and recent priced calls. Check before kicking off paid generation.',
82
111
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
83
112
  }, wrap(async () => {
@@ -86,7 +115,85 @@ export function registerTools(server) {
86
115
  return ok(`Balance: ${bal} credits${d.sessionUsed != null ? ` · session used: ${d.sessionUsed}` : ''}`, d);
87
116
  }));
88
117
 
118
+ // AGENT BILLING HANDOFF: out of credits → mint a ready-to-pay Stripe checkout link for a credit PACK and hand the
119
+ // URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
120
+ // this account automatically once payment completes. Packs only — subscriptions are managed by a person in-app.
121
+ server.registerTool('buy_credits', {
122
+ title: 'Buy credits',
123
+ description: "Out of credits? Get a ready-to-pay checkout link for a credit PACK. Call with no argument to list the available packs (id · credits · price); call again with `pack` set to a pack id to get a Stripe checkout URL. Hand that URL to your human — THEY pay on Stripe's secure hosted page (agents never spend money directly), and the credits land on this account the moment payment completes. Packs only; subscriptions are managed by a person in Settings → Billing. Nothing is charged until your human pays.",
124
+ inputSchema: {
125
+ pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
126
+ },
127
+ annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe's page
128
+ }, wrap(async ({ pack }) => {
129
+ const cfg = await apiGet('/api/billing/config');
130
+ const packs = (cfg.packs || []).map(p => ({ id: p.id, credits: p.credits, priceUsd: p.priceUsd }));
131
+ if (!pack) {
132
+ const lines = packs.map(p => `• ${p.id} — ${p.credits.toLocaleString()} credits · $${p.priceUsd}`).join('\n') || '(no packs configured)';
133
+ return ok(`Credit packs you can buy:\n${lines}\n\nCall buy_credits again with pack="<id>" to get a checkout link for your human to pay.`, { packs });
134
+ }
135
+ const match = packs.find(p => p.id === pack);
136
+ if (!match) return ok(`No pack "${pack}". Available: ${packs.map(p => p.id).join(', ') || '(none)'}. Call buy_credits with no argument to see details.`, { packs });
137
+ const d = await apiPost('/api/billing/checkout-link', { packId: pack });
138
+ return ok(`Checkout link for ${match.credits.toLocaleString()} credits ($${d.amountUsd ?? match.priceUsd}):\n${d.url}\n\nGive this URL to your human to pay on Stripe's secure page — the credits post to this account automatically once payment completes. Nothing is charged until they pay.`, d);
139
+ }));
140
+
141
+ // BILLING SURFACE (read → top-up → plan/auto-reload): hermoso_credits (balance) → buy_credits (top-up link) →
142
+ // billing_status (full picture + your role) → upgrade_plan / set_auto_reload (admin-only, pay-on-Stripe / in-app).
143
+ server.registerTool('billing_status', {
144
+ title: 'Billing status',
145
+ description: "Show this account's billing at a glance: current plan (id + label + monthly price), credit balance, whether auto-reload is on, whether a card is on file, and whether YOU (this key) have ADMIN rights to change billing. Read-only, free. Call it before upgrade_plan / set_auto_reload to know what's possible — members have read-only billing.",
146
+ inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
147
+ }, wrap(async () => {
148
+ const d = await apiGet('/api/billing/status');
149
+ const ar = d.autoReload || {};
150
+ const arLine = ar.available === false ? 'set in the app (not via API)' : (ar.enabled ? `on (below ${ar.thresholdCredits} cr → +${ar.reloadCredits} cr)` : 'off');
151
+ const text = `Plan: ${d.plan?.label} ($${d.plan?.monthlyUsd}/mo)\nBalance: ${d.balanceCredits} credits\nAuto-reload: ${arLine}\nCard on file: ${d.paymentMethodOnFile ? `yes${d.card ? ` (${d.card.brand} ····${d.card.last4})` : ''}` : 'no'}\nYour billing role: ${d.role}${d.isAdmin ? ' — you can change the plan / auto-reload' : ' — read-only; ask an admin to change the plan or auto-reload'}`;
152
+ return ok(text, d);
153
+ }));
154
+
155
+ // AGENT BILLING HANDOFF (plans): mint a ready-to-pay Stripe SUBSCRIPTION link for a NEW subscriber; existing-sub
156
+ // changes + downgrades are made in-app (the tool returns exactly what to do). Admin-only; a human always pays.
157
+ server.registerTool('upgrade_plan', {
158
+ title: 'Upgrade plan',
159
+ description: "Change this account's SUBSCRIPTION plan (admin only). Call with no argument to list the plans (id · monthly price · monthly credits); call again with `plan` set to a plan id. A NEW subscriber gets a ready-to-pay Stripe Checkout URL to hand your human — THEY pay on Stripe (agents never spend money directly). If the account already has a paid plan, or you're DOWNGRADING, the change is made by a person in the app (Settings → Billing) and the tool returns exactly what to do. Members (read-only billing) get an honest 'ask an admin' message. Nothing is charged until your human pays.",
160
+ inputSchema: {
161
+ plan: z.string().optional().describe('the plan id to move to (e.g. pro) — omit to list the available plans first'),
162
+ period: z.enum(['mo', 'yr']).optional().describe('billing cadence — monthly (default) or yearly (2 months free)'),
163
+ },
164
+ annotations: { readOnlyHint: true, openWorldHint: true }, // creates no server-side charge; the human pays on Stripe / in-app
165
+ }, wrap(async ({ plan, period }) => {
166
+ const cfg = await apiGet('/api/billing/config');
167
+ const plans = (cfg.plans || []).filter(p => p.priceUsd > 0).map(p => ({ id: p.id, name: p.name, priceUsd: p.priceUsd, credits: p.credits }));
168
+ if (!plan) {
169
+ const lines = plans.map(p => `• ${p.id} — ${p.name}: $${p.priceUsd}/mo · ${p.credits.toLocaleString()} credits/mo`).join('\n') || '(no plans configured)';
170
+ return ok(`Subscription plans:\n${lines}\n\nCall upgrade_plan again with plan="<id>" (admin only). Downgrades + changes for existing subscribers are made in the app.`, { plans });
171
+ }
172
+ const d = await apiPost('/api/billing/plan-link', { planId: plan, period });
173
+ if (d.mode === 'checkout') return ok(`Checkout link for the ${d.planLabel} plan ($${d.monthlyUsd}/mo${d.period === 'yr' ? `, billed $${d.chargeUsd}/yr` : ''}):\n${d.url}\n\nGive this URL to your human to subscribe on Stripe's secure page. Nothing is charged until they pay.`, d);
174
+ return ok(d.guidance, d); // in_app — an existing-subscriber upgrade or a downgrade (done by a person in the app)
175
+ }));
176
+
177
+ // Standing auto-reload config — a REAL server-side write now (persists on the account + fires even with no app open).
178
+ // Admin-only; requires a card on file (added ONCE in the app, then agents manage top-ups/auto-reload/plan links fully).
179
+ server.registerTool('set_auto_reload', {
180
+ title: 'Set auto-reload',
181
+ description: "Turn automatic credit reloads on or off (admin only): when the balance drops below a threshold, the card on file is charged for a top-up pack — SERVER-SIDE, even with no app open. Requires a saved card, added once in the app at first checkout/top-up; if there's none the tool tells you exactly where to add it. After that one-time card setup, agents can manage auto-reload, top-ups and plan links fully. Members (read-only billing) get an 'ask an admin' message.",
182
+ inputSchema: {
183
+ enabled: z.boolean().describe('true to turn auto-reload on, false to turn it off'),
184
+ thresholdCredits: z.number().int().optional().describe('reload when the balance drops below this many credits'),
185
+ reloadCredits: z.number().int().optional().describe('how many credits to add each reload — must match a credit pack size (see buy_credits)'),
186
+ },
187
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
188
+ }, wrap(async ({ enabled, thresholdCredits, reloadCredits }) => {
189
+ const d = await apiPost('/api/billing/autoreload-config', { enabled, thresholdCredits, reloadCredits });
190
+ if (d.needsCard) return ok(d.guidance || 'Add a card on file first (in the app), then auto-reload can use it.', d);
191
+ if (d.applied) return ok(`Auto-reload ${d.enabled ? `ON — reloads${d.reloadCredits != null ? ' +' + d.reloadCredits.toLocaleString() + ' credits' : ''} when the balance drops below ${d.thresholdCredits} credits` : 'OFF'}.`, d);
192
+ return ok(d.guidance || 'Manage auto-reload in the app: Settings → Billing → Auto-reload.', d);
193
+ }));
194
+
89
195
  server.registerTool('list_brands', {
196
+ title: 'List brands',
90
197
  description: "List every brand on this account (id + name) and which one this connection currently acts on. Multi-brand accounts: call this, then use_brand to switch. Read-only, free.",
91
198
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
92
199
  }, wrap(async () => {
@@ -96,9 +203,10 @@ export function registerTools(server) {
96
203
  }));
97
204
 
98
205
  server.registerTool('use_brand', {
206
+ title: 'Switch brand',
99
207
  description: "Pin which brand this connection generates for (multi-brand accounts). Pass the brand id or exact name from list_brands. Persists for this API key until changed.",
100
208
  inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
101
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
209
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
102
210
  }, wrap(async ({ brand }) => {
103
211
  const d = await apiGet('/api/brands');
104
212
  const want = String(brand || '').trim().toLowerCase();
@@ -110,6 +218,7 @@ export function registerTools(server) {
110
218
 
111
219
  // ---------- planning (LLM, 0 SC credits) ----------
112
220
  server.registerTool('plan_ad', {
221
+ title: 'Plan an ad concept',
113
222
  description: 'Creative director: turn a brand + product/brief into a finished ad CONCEPT — copy variants (headline/primary/cta) plus an image_concept.prompt OR a video_storyboard, with the resolved recipe + the model ids to render with. Renders nothing; chain its output into generate_image / generate_video. Spends LLM tokens, 0 ScrapeCreators credits.',
114
223
  inputSchema: {
115
224
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name, or a brand profile object {name,domain,category,palette,products,…}. OMIT to use the workspace’s SAVED brand + memory automatically (see get_brand); use draft_brand to onboard a new one'),
@@ -119,18 +228,23 @@ export function registerTools(server) {
119
228
  reference: z.string().optional().describe('a reference ad URL to remix the angle from — Facebook Ad Library, LinkedIn Ad Library or Google Ads Transparency links (the real ad’s copy/advertiser are fetched and fed into the concept)'),
120
229
  language: z.string().optional(),
121
230
  },
122
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
231
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
123
232
  }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
124
233
  const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
125
234
  const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
126
235
  const c = d.creative || d;
236
+ // EMBED THE PLAN'S OWN BRAND in the creative (2026-07-17: a multi-brand caller planned Fly By Jing but render_ad
237
+ // grounded on the account's SAVED brand — the video shipped with the WRONG brand's packshots and end lockup).
238
+ // /api/render/assemble prefers creative.brand, so "pass plan_ad's full output" now carries the right grounding.
239
+ if (brandObj && !c.brand) c.brand = { name: brandObj.name || '', domain: brandObj.domain || '', logo: brandObj.logo || '', sells: brandObj.sells || '', palette: (brandObj.palette || []).slice(0, 4), productImages: (brandObj.productImages || []).slice(0, 4) };
127
240
  const text = `Concept (${c.format}${c.recipe_label ? ' · ' + c.recipe_label : ''}): "${c.concept}"\nHeadline: ${c.copy?.[0]?.headline || ''}\nRender model: ${c.format === 'video' ? c.vmodel : c.imodel || '—'}. Next: ${c.format === 'video' ? 'call render_ad with THIS ENTIRE creative object (Studio quality pipeline; a ≤15s storyboard renders as ONE single-pass clip, a longer plan renders as stitched acts automatically — never hand-stitch)' : 'generate_image with the image_concept.prompt'}.`;
128
241
  return ok(text, c);
129
242
  }));
130
243
 
131
244
  // ---------- image (synchronous) ----------
132
245
  server.registerTool('generate_image', {
133
- description: 'Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). model = a catalog id from hermoso_capabilities (omit for the default). Fast (seconds). Spends credits.',
246
+ title: 'Generate ad image',
247
+ description: 'Render a finished ad IMAGE and return its served URL. refImages (local paths or URLs) force product-accurate compositing (drops a real product into the scene). MULTI-BRAND CAUTION: useBrand hydration pulls the SAVED workspace brand — when working a brand that is NOT the saved one (a fresh draft_brand), pass that brand\'s own productImages/logo as refImages (and useBrand:false) or the output composites the WRONG brand\'s product. model = a catalog id from hermoso_capabilities (omit for the default). Fast (seconds). Spends credits.',
134
248
  inputSchema: {
135
249
  prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
136
250
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
@@ -139,7 +253,7 @@ export function registerTools(server) {
139
253
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
140
254
  imageSize: z.string().optional(),
141
255
  },
142
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
256
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
143
257
  }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
144
258
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
145
259
  const d = await apiPost('/api/generate/image', { prompt, refImages: refs, useBrand: useBrand !== false, aspectRatio, model, imageSize }); // explicit boolean so the server's saved-brand hydration default is unambiguous
@@ -147,8 +261,37 @@ export function registerTools(server) {
147
261
  return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
148
262
  }));
149
263
 
264
+ // ---------- raw playground: voice (TTS) + writing models ----------
265
+ server.registerTool('generate_voice', {
266
+ title: 'Generate voiceover',
267
+ description: "RAW text-to-speech from the voice-model catalog: speak a script in a chosen voice and return the served MP3 URL. For a standalone voiceover / narration clip — NOT for adding audio to a video (render_ad and generate_video voice their own spots; change_voice re-voices a finished clip). engine picks the voice model (default 'seed-audio'; also 'eleven-v3', 'minimax-speech', 'kokoro'); voice is a preset name from that engine (see hermoso_capabilities → voice engines). Paid (a couple of credits by length; ≤900 characters).",
268
+ inputSchema: {
269
+ text: z.string().describe('the script to speak (≤900 characters)'),
270
+ engine: z.string().optional().describe("voice-engine id: 'seed-audio' (default), 'eleven-v3', 'minimax-speech', or 'kokoro' — listed in hermoso_capabilities"),
271
+ voice: z.string().optional().describe("a voice preset from the chosen engine (e.g. 'Aria'/'George' on eleven-v3, 'stokie_en' on seed-audio) — omit for the engine default"),
272
+ },
273
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
274
+ }, wrap(async ({ text, engine, voice }) => {
275
+ const d = await apiPost('/api/generate/voice', { text, ...(engine ? { engine } : {}), ...(voice ? { voice } : {}) });
276
+ return ok(`Voice clip ready — ${d.voice}${d.model ? ` · ${d.model}` : ''}: ${abs(d.audio)}`, { ...d, audio: abs(d.audio) });
277
+ }));
278
+
279
+ server.registerTool('generate_text', {
280
+ title: 'Generate text',
281
+ description: "RAW text generation against the writing-model catalog (Claude, Gemini, GPT, Llama, DeepSeek…) — ad copy, hooks, scripts, rewrites, brainstorms. Prompt-only, no ad assembly (for a finished on-brand creative use plan_ad → render_ad). model = a writing-model id from hermoso_capabilities (omit for the default Claude orchestrator). Paid (a credit or two by length).",
282
+ inputSchema: {
283
+ prompt: z.string().describe('the writing task / question'),
284
+ model: z.string().optional().describe('a writing-model id from hermoso_capabilities (a Claude / Gemini / GPT / Llama / DeepSeek id) — omit for the default'),
285
+ },
286
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
287
+ }, wrap(async ({ prompt, model }) => {
288
+ const d = await apiPost('/api/models/llm', { prompt, ...(model ? { model } : {}) });
289
+ return ok(`${d.text}${d.model ? `\n\n— ${d.model}` : ''}`, d);
290
+ }));
291
+
150
292
  // ---------- video / avatar / stitch (job-based, polled to completion) ----------
151
293
  server.registerTool('render_ad', {
294
+ title: 'Render ad video',
152
295
  description: 'RECOMMENDED for finished video ADS: render a plan_ad concept through the SAME quality pipeline as the Hermoso web Studio — timed shot list, exact/clean speech (no garbled words), text composited in post (never model-painted), brand end card, licensed music bed, real product references. Pass plan_ad’s full structured output as `creative`. Honors the plan’s render_plan structure/duration: a ≤15s storyboard renders as ONE single-pass clip; a longer plan automatically renders as STITCHED ACTS (fewest balanced ≤15s clips) — never time-compressed into one clip. Renders take 1–3 min; keep polling get_job if it returns still-rendering. Spends credits.',
153
296
  inputSchema: {
154
297
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
@@ -163,7 +306,7 @@ export function registerTools(server) {
163
306
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
164
307
  dryRun: z.boolean().optional().describe('return the routing decision (single pass vs stitched acts, resolved model + act lengths) WITHOUT submitting a render — free, nothing charged'),
165
308
  },
166
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
309
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
167
310
  }, wrap(async (a) => {
168
311
  const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
169
312
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
@@ -176,11 +319,12 @@ export function registerTools(server) {
176
319
 
177
320
 
178
321
  server.registerTool('make_template_ad', {
179
- description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook, claims: string[] (3-5, ≤4 words each), productImages: string[], palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
322
+ title: 'Make template ad',
323
+ description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
180
324
  inputSchema: {
181
325
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
182
326
  },
183
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
327
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
184
328
  }, wrap(async (a) => {
185
329
  const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
186
330
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
@@ -193,6 +337,7 @@ export function registerTools(server) {
193
337
  }));
194
338
 
195
339
  server.registerTool('finish_video', {
340
+ title: 'Finish video',
196
341
  description: "Post-process an EXISTING rendered video (its served mp4 URL) with the proven direct-response 'reviewer' finish and/or a film-grain pass — no AI model, ~30s, a couple of credits. pills=true composites a header pill (e.g. '10/10 would buy again'), a brand-accent sub-pill, and 3-4 green-check proof pills cascading in on the beat (YOU author the copy: header ≤40 chars, sub ≤34, each point ≤44 — concrete real benefits, never fabricated stats). grain=true applies a subtle camera-grain finish that makes photoreal AI renders look phone-shot ('less AI') — works alone or with pills. Returns a NEW video; the original is untouched.",
197
342
  inputSchema: {
198
343
  videoUrl: z.string().describe('the served URL of the video to finish (from a previous render/job)'),
@@ -203,13 +348,14 @@ export function registerTools(server) {
203
348
  pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
204
349
  grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
205
350
  },
206
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
351
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
207
352
  }, wrap(async (a) => {
208
353
  const r = await renderJob('videofinish', { videoUrl: a.videoUrl, header: a.header, sub: a.sub, points: a.points, accent: a.accent, pills: a.pills !== false, grain: !!a.grain }, 'MCP video finish');
209
354
  return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
210
355
  }));
211
356
 
212
357
  server.registerTool('fix_beat', {
358
+ title: 'Fix a video beat',
213
359
  description: "Surgically re-render ONE time window (1.5-8s) of an existing rendered video and splice it back on the VIDEO TRACK ONLY — the rest of the video and ALL audio stay byte-identical. Use when one beat/shot is broken ('the shot at 8 seconds glitches') and a full re-render would waste the parts that worked; bills only the replacement clip's seconds (~1/3 of a full render). Do NOT pick a window covering spoken dialogue (a video-only splice under speech breaks lip-sync) — pass speechWindows to enforce this.",
214
360
  inputSchema: {
215
361
  videoUrl: z.string().describe('the served URL of the master video to fix'),
@@ -219,17 +365,19 @@ export function registerTools(server) {
219
365
  refImage: z.string().optional().describe('optional product/style anchor image URL'),
220
366
  speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
221
367
  },
222
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
368
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
223
369
  }, wrap(async (a) => {
224
370
  const r = await renderJob('fixbeat', { videoUrl: a.videoUrl, startSeconds: a.startSeconds, endSeconds: a.endSeconds, prompt: a.prompt, refImage: a.refImage, speechWindows: a.speechWindows }, 'MCP fix beat');
225
371
  return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
226
372
  }));
227
373
 
228
374
  server.registerTool('generate_video', {
229
- description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Spends credits (Starter plan is video-blocked server-side).',
375
+ title: 'Generate video',
376
+ description: 'Render a RAW video clip from your own prompt and return its served mp4 URL. For finished brand ADS prefer render_ad (it runs the Studio quality pipeline — composited text, clean speech, end card, music); use this for raw/experimental clips or precise manual control. ONE generation = one continuous clip up to the model’s longest listed duration (seedance-2 goes to 15s single-pass with a full multi-beat arc — never assume a generic 8–10s cap); durationSeconds must be one of the model’s durations from hermoso_capabilities. Renders take 1–3 min. refImage anchors the opening frame; ttsScript adds a voiceover. Pass refVideo (a clip URL) to EDIT an existing video instead of generating from scratch — the omni engine transforms that clip per your prompt, inheriting the source clip’s canvas + length (aspectRatio/durationSeconds are ignored for an edit). Spends credits (Starter plan is video-blocked server-side).',
230
377
  inputSchema: {
231
- prompt: z.string().describe('the video prompt / shot description'),
378
+ prompt: z.string().describe('the video prompt / shot description (for a refVideo edit, this is the transformation instruction)'),
232
379
  refImage: z.string().optional().describe('local path or URL to anchor the first frame'),
380
+ refVideo: z.string().optional().describe("URL of an existing video to EDIT rather than generate from scratch — the omni engine accepts a raw clip and transforms it per your prompt, inheriting the SOURCE clip’s canvas (aspect ratio) and length (aspectRatio/durationSeconds are ignored for an edit). Omit to generate a fresh clip."),
233
381
  durationSeconds: z.number().optional().describe('clip length in seconds'),
234
382
  aspectRatio: z.string().optional().describe("default '9:16'"),
235
383
  model: z.string().optional().describe('video model id from hermoso_capabilities. Naming one is a DELIBERATE pick — the server asks before ever swapping it (no silent fallback); omit it to let the router pick'),
@@ -238,7 +386,7 @@ export function registerTools(server) {
238
386
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
239
387
  musicMood: z.string().optional(),
240
388
  },
241
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
389
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
242
390
  }, wrap(async (a) => {
243
391
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
244
392
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
@@ -248,6 +396,7 @@ export function registerTools(server) {
248
396
  }));
249
397
 
250
398
  server.registerTool('generate_avatar', {
399
+ title: 'Generate talking avatar',
251
400
  description: 'Render a TALKING-AVATAR / creator lip-sync clip from a portrait image + a script. Blocks until done (1–3 min). Requires the avatar capability (canAvatar in hermoso_capabilities). Spends credits.',
252
401
  inputSchema: {
253
402
  image: z.string().describe('local path or URL of the presenter portrait'),
@@ -255,7 +404,7 @@ export function registerTools(server) {
255
404
  voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
256
405
  resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
257
406
  },
258
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
407
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
259
408
  }, wrap(async (a) => {
260
409
  const image = await toRef(a.image);
261
410
  const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
@@ -263,6 +412,7 @@ export function registerTools(server) {
263
412
  }));
264
413
 
265
414
  server.registerTool('stitch_video', {
415
+ title: 'Stitch multi-scene video',
266
416
  description: 'Render a multi-scene STITCHED video (≥2 scenes) — ONLY for spots LONGER than one model clip (>15s). A ≤15s multi-beat ad renders better and cheaper as ONE single-pass generate_video/render_ad on seedance-2 (it handles the full hook→demo→payoff arc in one take) — never stitch those. Blocks until done. Spends credits.',
267
417
  inputSchema: {
268
418
  scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
@@ -273,7 +423,7 @@ export function registerTools(server) {
273
423
  model: z.string().optional(),
274
424
  durationSeconds: z.number().optional(),
275
425
  },
276
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
426
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
277
427
  }, wrap(async (a) => {
278
428
  // HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
279
429
  // clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
@@ -295,6 +445,7 @@ export function registerTools(server) {
295
445
  }));
296
446
 
297
447
  server.registerTool('get_job', {
448
+ title: 'Get render job',
298
449
  description: 'Poll a render job by id. Returns status (queued|running|done|error), progress, and on done the served media URL. Renders take 1–3 minutes: keep calling this until done/error without asking the user — several calls is normal, not a stall.',
299
450
  inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
300
451
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -310,6 +461,7 @@ export function registerTools(server) {
310
461
 
311
462
  // ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
312
463
  server.registerTool('list_skills', {
464
+ title: 'List skills',
313
465
  description: 'List the bundled Hermoso SKILLS — multi-step workflow instructions (SKILL.md) that orchestrate the other tools (research an ad space, plan+render a finished ad, product photoshoot, raw generation) — plus the in-app strategy skills and creative recipes. Call get_skill to load a bundle. Read-only, free.',
314
466
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
315
467
  }, wrap(async () => {
@@ -333,6 +485,7 @@ export function registerTools(server) {
333
485
  }));
334
486
 
335
487
  server.registerTool('get_skill', {
488
+ title: 'Get skill',
336
489
  description: 'Load a bundled skill’s full SKILL.md workflow instructions by name (from list_skills). Follow the loaded instructions to run that workflow with the other tools. Read-only, free.',
337
490
  inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
338
491
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -345,6 +498,7 @@ export function registerTools(server) {
345
498
  }));
346
499
 
347
500
  server.registerTool('list_jobs', {
501
+ title: 'List render jobs',
348
502
  description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
349
503
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
350
504
  }, wrap(async () => {
@@ -355,6 +509,7 @@ export function registerTools(server) {
355
509
 
356
510
  // ---------- research / discovery ----------
357
511
  server.registerTool('find_competitors', {
512
+ title: 'Find competitors',
358
513
  description: "Discover a brand's competitor / similar / adjacent brands from its domain (Claude grounded by web search). mode=competitors (default, excludes the searched company), inspiration (best relevant ads incl. it), or company. 0 ScrapeCreators credits.",
359
514
  inputSchema: {
360
515
  domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
@@ -368,6 +523,7 @@ export function registerTools(server) {
368
523
  }));
369
524
 
370
525
  server.registerTool('pull_competitor_ads', {
526
+ title: 'Pull competitor ads',
371
527
  description: 'Pull a brand\'s real running ads across Meta / Google / LinkedIn ad libraries (deduped, sorted, right page resolved). Spends ScrapeCreators credits.',
372
528
  inputSchema: {
373
529
  companyName: z.string().optional().describe('the advertiser name'),
@@ -384,6 +540,7 @@ export function registerTools(server) {
384
540
  }));
385
541
 
386
542
  server.registerTool('research_ads', {
543
+ title: 'Research ads',
387
544
  description: 'Natural-language ad research: a Claude tool-use loop over Meta/Google/LinkedIn ad libraries + organic TikTok. Returns a summary + the found ads (with their served URLs). Spends LLM tokens + ScrapeCreators credits.',
388
545
  inputSchema: {
389
546
  query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
@@ -405,6 +562,7 @@ export function registerTools(server) {
405
562
  const adsOut = (label, total, items) => ok(JSON.stringify({ found: total, showing: items.length, [label]: items }), { found: total, [label]: items }); // compact JSON summary, never the raw firehose
406
563
 
407
564
  server.registerTool('search_meta_ads', {
565
+ title: 'Search Meta ads',
408
566
  description: "Structured Meta (Facebook/Instagram) Ad Library pull — use when you know exactly WHAT to fetch: a keyword (query) OR one advertiser (companyName / pageId). Returns compact JSON {page_name, body, cta, link, dates, media} per ad. For open-ended research that needs judgment across platforms, use research_ads instead. Spends ScrapeCreators credits (~1–2).",
409
567
  inputSchema: {
410
568
  query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
@@ -435,6 +593,7 @@ export function registerTools(server) {
435
593
  }));
436
594
 
437
595
  server.registerTool('search_google_ads', {
596
+ title: 'Search Google ads',
438
597
  description: "Structured Google Ads Transparency pull for ONE advertiser (by domain or advertiserId) — use when you know the brand; use research_ads for open-ended research. Deliberately fetches the cheap BASIC listing (get_ad_details=false, ~1 credit — the detailed variant with per-ad headlines costs 25 credits/call and is not exposed here). Returns compact JSON {advertiser, format, adUrl, image, firstShown, lastShown} per ad.",
439
598
  inputSchema: {
440
599
  domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
@@ -452,6 +611,7 @@ export function registerTools(server) {
452
611
  }));
453
612
 
454
613
  server.registerTool('search_linkedin_ads', {
614
+ title: 'Search LinkedIn ads',
455
615
  description: "Structured LinkedIn Ad Library search by company name, keyword, or companyId — use for a targeted B2B pull; use research_ads for open-ended research. Returns compact JSON {advertiser, headline, description, cta, link, media, dates, impressions} per ad — LinkedIn is the one library exposing real impression counts. Spends ScrapeCreators credits (~1).",
456
616
  inputSchema: {
457
617
  company: z.string().optional().describe('advertiser company name'),
@@ -473,6 +633,7 @@ export function registerTools(server) {
473
633
  }));
474
634
 
475
635
  server.registerTool('search_tiktok', {
636
+ title: 'Search TikTok',
476
637
  description: "Organic TikTok keyword search (there is NO TikTok ad library) — top-performing videos to mine for hooks/trends/remixable creative. Returns compact JSON {desc, author, handle, plays, likes, link, cover} per video, ranked by plays. Use research_ads for open-ended research. Spends ScrapeCreators credits (~1).",
477
638
  inputSchema: {
478
639
  query: z.string().describe('keyword or hashtag (no # needed)'),
@@ -493,6 +654,7 @@ export function registerTools(server) {
493
654
  }));
494
655
 
495
656
  server.registerTool('search_instagram', {
657
+ title: 'Search Instagram',
496
658
  description: "Organic Instagram REELS keyword search (/v2/instagram/reels/search — ScrapeCreators' only IG keyword surface; profile/hashtag pulls go through scrapecreators_fetch with a handle). Returns compact JSON {desc, author, handle, plays, likes, link, cover} per reel, ranked by plays. Spends ScrapeCreators credits (~1).",
497
659
  inputSchema: {
498
660
  query: z.string().describe('keyword to search reels for'),
@@ -515,6 +677,7 @@ export function registerTools(server) {
515
677
  }));
516
678
 
517
679
  server.registerTool('search_youtube', {
680
+ title: 'Search YouTube',
518
681
  description: "Organic YouTube keyword search (/v1/youtube/search) — videos to mine for hooks/angles/long-form structure. Returns compact JSON {desc (title), author, handle, plays, link, cover} per video, ranked by views. Spends ScrapeCreators credits (~1).",
519
682
  inputSchema: {
520
683
  query: z.string().describe('keyword to search videos for'),
@@ -531,6 +694,7 @@ export function registerTools(server) {
531
694
  }));
532
695
 
533
696
  server.registerTool('search_reddit', {
697
+ title: 'Search Reddit',
534
698
  description: "Reddit keyword search (/v1/reddit/search, top-ranked) — a goldmine for the customer's OWN words (pain points, objections, language) to mine into ad hooks and copy. Returns compact JSON {desc (title+selftext), subreddit, upvotes, comments, link} per post. Spends ScrapeCreators credits (~1).",
535
699
  inputSchema: {
536
700
  query: z.string().describe('what to search Reddit for'),
@@ -548,6 +712,7 @@ export function registerTools(server) {
548
712
  }));
549
713
 
550
714
  server.registerTool('search_threads', {
715
+ title: 'Search Threads',
551
716
  description: "Organic Threads keyword search (/v1/threads/search) — short-form text/social posts for trend + voice research. Returns compact JSON {desc, author, handle, likes, link, cover} per post. Spends ScrapeCreators credits (~1).",
552
717
  inputSchema: {
553
718
  query: z.string().describe('keyword to search Threads for'),
@@ -569,6 +734,7 @@ export function registerTools(server) {
569
734
  }));
570
735
 
571
736
  server.registerTool('scrapecreators_fetch', {
737
+ title: 'Fetch ScrapeCreators endpoint',
572
738
  description: "Generic ScrapeCreators escape hatch for any ALLOWLISTED long-tail endpoint the dedicated search_* tools don't cover — e.g. {path:'/v1/instagram/profile', params:{handle:'nike'}}. Allowlisted platform families: TikTok (+ TikTok Shop), Instagram, YouTube, Facebook (organic profiles/posts/events/marketplace), LinkedIn (organic posts/companies), Twitter/X, Reddit, Threads, Snapchat, Pinterest, Twitch, Bluesky, Truth Social, Rumble, Spotify, SoundCloud, GitHub, Google search, link-in-bio pages (Linktree etc.). Param names vary per endpoint (profiles use `handle`, keyword searches use `query`, Reddit uses `subreddit`). WARNING: returns RAW provider JSON — large and messy; prefer the dedicated search_* tools. Spends ScrapeCreators credits.",
573
739
  inputSchema: {
574
740
  path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
@@ -583,6 +749,7 @@ export function registerTools(server) {
583
749
 
584
750
  // ---------- brand onboarding ----------
585
751
  server.registerTool('get_brand', {
752
+ title: 'Get saved brand',
586
753
  description: 'What Hermoso ALREADY KNOWS for this account/workspace — the same saved brand profile (products, logos, palette, positioning) + learned memory the web Studio uses. Call this FIRST: if hasBrand is true you can omit brand everywhere; if false, onboard with draft_brand. 0 credits.',
587
754
  inputSchema: {},
588
755
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
@@ -595,7 +762,8 @@ export function registerTools(server) {
595
762
  }));
596
763
 
597
764
  server.registerTool('draft_brand', {
598
- description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 ScrapeCreators credits.',
765
+ title: 'Draft brand profile',
766
+ description: 'Onboard a brand profile — from a website domain, a free-text description, or a social handle — into a {name, products, logo, …} object you can pass to plan_ad / generate. 0 ScrapeCreators credits. IMPORTANT: a domain can resolve to a DIFFERENT company than intended (e.g. bala.com is an engineering firm, not the Bala fitness brand at shopbala.com). Before spending any credits on research or renders, VERIFY the returned `name` (and `summary`) match the brand the user meant; if it looks wrong, re-draft with the correct domain or a description (pass save:false until confirmed) — this tool cannot ask the user, so the caller owns that check.',
599
767
  inputSchema: {
600
768
  domain: z.string().optional().describe('a website to scrape'),
601
769
  description: z.string().optional().describe('a free-text brand description (no website)'),
@@ -603,7 +771,7 @@ export function registerTools(server) {
603
771
  platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
604
772
  save: z.boolean().optional().describe('save as the workspace’s brand (like Studio onboarding) so plan_ad/create use it automatically. Default: saves only when NO brand is saved yet; pass true to overwrite, false to never save'),
605
773
  },
606
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
774
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
607
775
  }, wrap(async ({ save, ...a }) => {
608
776
  const d = await apiPost('/api/brand/draft', a);
609
777
  const p = d.profile || d;
@@ -623,6 +791,7 @@ export function registerTools(server) {
623
791
 
624
792
  // ---------- assets ----------
625
793
  server.registerTool('fetch_asset', {
794
+ title: 'Fetch asset',
626
795
  description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
627
796
  inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
628
797
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -634,6 +803,7 @@ export function registerTools(server) {
634
803
 
635
804
  // ---------- post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route) ----------
636
805
  server.registerTool('analyze_video', {
806
+ title: 'Analyze video',
637
807
  description: "Break a video ad down into its structure: the verbatim transcript (voiceover + on-screen text) with a beat list, plus duration and sampled frame timestamps. Use to study a reference/competitor ad before remixing its structure. Costs ~a transcription call; no ScrapeCreators credits.",
638
808
  inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
639
809
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -648,6 +818,7 @@ export function registerTools(server) {
648
818
  }));
649
819
 
650
820
  server.registerTool('score_ad', {
821
+ title: 'Score ad',
651
822
  description: "Virality/performance prediction for a finished ad (image or video URL): overall score, per-dimension breakdown (scroll-stop, hook, clarity, brand/product, CTA, retention, goal fit), strengths, and the single biggest fix. Use BEFORE spending on distribution, or to rank variants.",
652
823
  inputSchema: {
653
824
  url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
@@ -663,49 +834,54 @@ export function registerTools(server) {
663
834
  }));
664
835
 
665
836
  server.registerTool('reframe_video', {
837
+ title: 'Reframe video',
666
838
  description: "Reframe a video to a different aspect ratio (e.g. 16:9 master → 9:16 vertical) with smart subject tracking. Paid render; returns the served URL of the reframed video.",
667
839
  inputSchema: { video: z.string().describe('the source video URL'), aspectRatio: z.enum(['9:16', '1:1', '16:9', '4:3', '3:4', '21:9', '9:21']).describe('the target aspect ratio') },
668
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
840
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
669
841
  }, wrap(async ({ video, aspectRatio }) => {
670
842
  const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
671
843
  return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
672
844
  }));
673
845
 
674
846
  server.registerTool('upscale_video', {
847
+ title: 'Upscale video',
675
848
  description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
676
849
  inputSchema: { video: z.string().describe('the source video URL') },
677
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
850
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
678
851
  }, wrap(async ({ video }) => {
679
852
  const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
680
853
  return okVideo(`Upscaled video: ${r.url}`, r);
681
854
  }));
682
855
 
683
856
  server.registerTool('dub_video', {
857
+ title: 'Dub video',
684
858
  description: "Remake a finished video ad's voiceover in another language (translated script, re-voiced, re-muxed). Paid; returns the served URL of the localized video.",
685
859
  inputSchema: {
686
860
  video: z.string().describe('the source video URL'),
687
861
  language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
688
862
  script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
689
863
  },
690
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
864
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
691
865
  }, wrap(async ({ video, language, script }) => {
692
866
  const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
693
867
  return okVideo(`Localized video (${language}): ${r.url}`, r);
694
868
  }));
695
869
 
696
870
  server.registerTool('change_voice', {
871
+ title: 'Change narrator voice',
697
872
  description: "Swap the narration of a finished video into a different voice — keeps the performance, lip-sync, and background sound. Use when the user likes the video but wants a different narrator voice; use dub_video only for language translation. Paid; returns the served URL.",
698
873
  inputSchema: {
699
874
  video: z.string().describe('the source video URL'),
700
875
  voice: z.string().optional().describe("target narrator voice preset name, e.g. 'Aria', 'George', 'Rachel', 'Sarah', 'Brian', 'Charlotte' (defaults to a warm female read)"),
701
876
  },
702
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
877
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
703
878
  }, wrap(async ({ video, voice }) => {
704
879
  const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
705
880
  return okVideo(`Voice-swapped video: ${r.url}`, r);
706
881
  }));
707
882
 
708
883
  server.registerTool('recast_motion', {
884
+ title: 'Recast motion',
709
885
  description: "Motion transfer: re-perform a reference video's motion with a different person/character (supply their image). The reference clip drives the movement; the image supplies the identity. Paid render.",
710
886
  inputSchema: {
711
887
  image: z.string().describe("the actor/character image URL (who should appear)"),
@@ -713,13 +889,14 @@ export function registerTools(server) {
713
889
  prompt: z.string().optional().describe('optional scene/style guidance'),
714
890
  orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
715
891
  },
716
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
892
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
717
893
  }, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
718
894
  const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
719
895
  return okVideo(`Recast video: ${r.url}`, r);
720
896
  }));
721
897
 
722
898
  server.registerTool('plan_variations', {
899
+ title: 'Plan ad variations',
723
900
  description: "Fan a brief into N DISTINCT ad angles (different hooks/mechanics/audiences), each with its own headline + visual brief — then render each with generate_image and rank with score_ad. LLM planning only; renders nothing itself.",
724
901
  inputSchema: {
725
902
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
@@ -727,7 +904,7 @@ export function registerTools(server) {
727
904
  count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
728
905
  language: z.string().optional(),
729
906
  },
730
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
907
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
731
908
  }, wrap(async ({ brand, product, count = 6, language }) => {
732
909
  const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
733
910
  const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
@@ -753,13 +930,14 @@ export function registerTools(server) {
753
930
  };
754
931
 
755
932
  server.registerTool('competitor_teardown', {
933
+ title: 'Competitor teardown',
756
934
  description: "Tear a competitor's ad strategy down into an actionable playbook: their opening-hook MIX, longest-running campaign THEMES, the WHITE SPACE nobody in their set runs, 2-3 render-ready COUNTER-PLAYS, and the territories they own that you should avoid. Pass `competitor` {name, domain?}. CONTRACT: supply `ads` (raw ad objects from a prior pull_competitor_ads / search_meta_ads call) to tear exactly those down, OR omit `ads` and this pulls the competitor's real Meta ads first (spends ~1-2 ScrapeCreators credits, longest-running = proven winners). Auto-tailors the white space + counter-plays to YOUR saved brand. Spends LLM tokens (0 SC credits when you pass ads).",
757
935
  inputSchema: {
758
936
  competitor: z.object({ name: z.string().describe('the competitor brand name'), domain: z.string().optional().describe('their domain — sharpens the auto-pull page match') }).describe('the competitor to tear down'),
759
937
  ads: z.array(z.object({}).passthrough()).optional().describe('ad objects to tear down (from pull_competitor_ads / search_meta_ads). Omit to auto-pull their Meta ads first.'),
760
938
  language: z.string().optional().describe('output language (default English)'),
761
939
  },
762
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
940
+ annotations: { readOnlyHint: true, openWorldHint: true },
763
941
  }, wrap(async ({ competitor, ads, language }) => {
764
942
  const name = String(competitor?.name || '').trim();
765
943
  if (!name) throw new Error('competitor.name is required.');
@@ -781,6 +959,7 @@ export function registerTools(server) {
781
959
  }));
782
960
 
783
961
  server.registerTool('check_ad_policy', {
962
+ title: 'Check ad policy',
784
963
  description: "Pre-flight ad copy against Meta's REAL, live Advertising Standards before you run it — a flat 1-credit check. Pulls Meta's actual policy pages and returns a verdict (pass / fix / block) where every flagged issue QUOTES Meta's own policy text verbatim plus a compliant rewrite that keeps the sell. It's a check, not an edit — it never changes the creative. Especially worth running for regulated-adjacent categories (health/supplements, weight-loss or beauty results claims, finance/crypto/insurance, alcohol, dating, gambling) or ANY strong/absolute/guaranteed claim.",
785
964
  inputSchema: {
786
965
  copy: z.string().describe('the ad copy / script / on-screen text to check'),
@@ -798,12 +977,13 @@ export function registerTools(server) {
798
977
  }));
799
978
 
800
979
  server.registerTool('remix_static', {
980
+ title: 'Remix a static ad',
801
981
  description: "One-click STATIC-AD REMIX: rebuild a competitor/reference STATIC (image) ad as an on-brand version — SAME layout, composition and energy, but YOUR product, brand colours, logo and voice, with every trace of the source brand removed. Pass `imageUrl` = the static ad image to remix. Uses your saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). IMAGES ONLY — for video ads use render_ad. Bills as one image generation.",
802
982
  inputSchema: {
803
983
  imageUrl: z.string().describe('the URL of the static ad image to remix'),
804
984
  brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
805
985
  },
806
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
986
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
807
987
  }, wrap(async ({ imageUrl, brandId }) => {
808
988
  const brand = await activeBrand(brandId);
809
989
  if (!brand) throw new Error('No saved brand to remix for — onboard one with draft_brand, or pass a brandId from list_brands.');
@@ -816,6 +996,7 @@ export function registerTools(server) {
816
996
  }));
817
997
 
818
998
  server.registerTool('mine_angles', {
999
+ title: 'Mine customer angles',
819
1000
  description: "Mine ad ANGLES from real customer language: gathers the customer's own words (Reddit, TikTok, the brand's review page + review-site results) and returns a RANKED angle bank — each angle tagged (pain / outcome / identity / fear / competitive-displacement / social-proof / contrast), 2-5 VERBATIM proof quotes, a 0-100 score with breakdown, and a ready-to-run hook in the customer's own voice. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand). To tear down a COMPETITOR use competitor_teardown instead. Spends a few ScrapeCreators credits + LLM tokens.",
820
1001
  inputSchema: {
821
1002
  brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
@@ -833,6 +1014,7 @@ export function registerTools(server) {
833
1014
 
834
1015
  // ---------- product-photo tools (Studio-chat parity) ----------
835
1016
  server.registerTool('list_product_photos', {
1017
+ title: 'List product photos',
836
1018
  description: "List the product photos ALREADY saved in your workspace — the brand's product library plus any app-store screens (also surfaces photos locked in your OTHER creations, since a set product lands in the shared library). FREE — returns each photo's url + label. Call it before set_product_image to see the existing photos you can reuse. Reads YOUR saved brand (pass brandId to target a specific brand — that switches this key's active brand like use_brand).",
837
1019
  inputSchema: {
838
1020
  brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
@@ -845,13 +1027,14 @@ export function registerTools(server) {
845
1027
  }));
846
1028
 
847
1029
  server.registerTool('set_product_image', {
1030
+ title: 'Set product photo',
848
1031
  description: "Lock an image as the ad's real PRODUCT photo so every render grounds on the true packaging. Pass `imageUrl` = a product shot's URL — an image from a prior research result (an organic Instagram/TikTok post, a scraped page image), a workspace / list_product_photos url, or any public product photo. The server downloads it and runs a product+safety check: a lifestyle/scene shot with no clear product, or an off-category / unsafe image, is REJECTED and NOTHING is locked (the summary says why). On PASS it persists the photo to a DURABLE url and returns it — pass that url as a reference to generate_image / render_ad. Bills one vision check. Reads YOUR saved brand for the category match (pass brandId to target a specific brand — switches this key's active brand like use_brand).",
849
1032
  inputSchema: {
850
1033
  imageUrl: z.string().describe('the image URL to lock as the product (from a research result, a workspace / list_product_photos url, or any public product photo)'),
851
1034
  source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
852
1035
  brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
853
1036
  },
854
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1037
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
855
1038
  }, wrap(async ({ imageUrl, source_note, brandId }) => {
856
1039
  const brand = await activeBrand(brandId);
857
1040
  const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.4",
4
- "description": "Drive Hermoso — the AI ad studio — from any AI agent: MCP server, CLI, and Claude skills for researching winning ads and generating finished image & video ads.",
3
+ "version": "0.1.6",
4
+ "mcpName": "io.github.hermoso-ai/hermoso",
5
+ "description": "Generate finished VIDEO ADS, image ads and UGC avatar ads for any brand with AI \u2014 and spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus TikTok/Instagram/YouTube organic. MCP server, CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "hermoso": "bin/hermoso.mjs"
@@ -13,14 +14,26 @@
13
14
  "@modelcontextprotocol/sdk": "^1.12.0"
14
15
  },
15
16
  "keywords": [
16
- "mcp",
17
- "model-context-protocol",
18
- "ai-ads",
19
17
  "ad-generator",
20
- "ugc",
18
+ "ad-library",
19
+ "ads",
20
+ "advertising",
21
+ "ai-ads",
22
+ "ai-agents",
23
+ "ai-video",
21
24
  "claude",
22
25
  "cli",
23
- "video-ads"
26
+ "competitor-analysis",
27
+ "image-generation",
28
+ "marketing",
29
+ "mcp",
30
+ "mcp-server",
31
+ "model-context-protocol",
32
+ "seedance",
33
+ "ugc",
34
+ "veo",
35
+ "video-ads",
36
+ "video-generation"
24
37
  ],
25
38
  "homepage": "https://hermoso.ai",
26
39
  "repository": {