hermoso 0.1.5 → 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.
@@ -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 += `\nRun buy_credits to get a ready-to-pay checkout link (credit packs only; your human pays on Stripe's secure page — nothing was charged here). Or top up / upgrade at https://app.hermoso.ai (Settings Billing). 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,6 +89,7 @@ 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 () => {
@@ -73,11 +98,15 @@ export function registerTools(server) {
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
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('; ');
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(', ')}…`;
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 () => {
@@ -90,6 +119,7 @@ export function registerTools(server) {
90
119
  // URL to the human. The human pays on Stripe's hosted page (agents never spend money directly); credits post to
91
120
  // this account automatically once payment completes. Packs only — subscriptions are managed by a person in-app.
92
121
  server.registerTool('buy_credits', {
122
+ title: 'Buy credits',
93
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.",
94
124
  inputSchema: {
95
125
  pack: z.string().optional().describe('the pack id to buy (e.g. pack-2k) — omit to list the available packs first'),
@@ -108,7 +138,62 @@ export function registerTools(server) {
108
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);
109
139
  }));
110
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
+
111
195
  server.registerTool('list_brands', {
196
+ title: 'List brands',
112
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.",
113
198
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
114
199
  }, wrap(async () => {
@@ -118,9 +203,10 @@ export function registerTools(server) {
118
203
  }));
119
204
 
120
205
  server.registerTool('use_brand', {
206
+ title: 'Switch brand',
121
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.",
122
208
  inputSchema: { brand: z.string().describe('brand id (e.g. default / p_xxx) or its exact name from list_brands') },
123
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
209
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
124
210
  }, wrap(async ({ brand }) => {
125
211
  const d = await apiGet('/api/brands');
126
212
  const want = String(brand || '').trim().toLowerCase();
@@ -132,6 +218,7 @@ export function registerTools(server) {
132
218
 
133
219
  // ---------- planning (LLM, 0 SC credits) ----------
134
220
  server.registerTool('plan_ad', {
221
+ title: 'Plan an ad concept',
135
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.',
136
223
  inputSchema: {
137
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'),
@@ -141,18 +228,23 @@ export function registerTools(server) {
141
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)'),
142
229
  language: z.string().optional(),
143
230
  },
144
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
231
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
145
232
  }, wrap(async ({ brand, product, format = 'auto', recipe, reference, language }) => {
146
233
  const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null; // null → the server hydrates the workspace's saved brand/memory/taste
147
234
  const d = await apiPost('/api/create', { brand: brandObj, product, format, recipe: recipe || '', reference: reference ? { url: reference } : null, language: language || '' });
148
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) };
149
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'}.`;
150
241
  return ok(text, c);
151
242
  }));
152
243
 
153
244
  // ---------- image (synchronous) ----------
154
245
  server.registerTool('generate_image', {
155
- 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.',
156
248
  inputSchema: {
157
249
  prompt: z.string().describe('the full image prompt — subject, composition, lighting, and any on-image ad text'),
158
250
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in'),
@@ -161,7 +253,7 @@ export function registerTools(server) {
161
253
  model: z.string().optional().describe('image model id from hermoso_capabilities'),
162
254
  imageSize: z.string().optional(),
163
255
  },
164
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
256
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
165
257
  }, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize }) => {
166
258
  const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
167
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
@@ -169,8 +261,37 @@ export function registerTools(server) {
169
261
  return { content: [{ type: 'text', text: `Image ready: ${abs(d.image)}${d.model ? ` (${d.model})` : ''}` }, ...(img ? [img] : [])], structuredContent: { ...d, image: abs(d.image) } };
170
262
  }));
171
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
+
172
292
  // ---------- video / avatar / stitch (job-based, polled to completion) ----------
173
293
  server.registerTool('render_ad', {
294
+ title: 'Render ad video',
174
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.',
175
296
  inputSchema: {
176
297
  creative: z.object({}).passthrough().describe('the FULL structured output of plan_ad (must contain video_storyboard)'),
@@ -185,7 +306,7 @@ export function registerTools(server) {
185
306
  ttsVoice: z.string().optional().describe('voiceover voice name (e.g. Rachel / George) when the plan voices over'),
186
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'),
187
308
  },
188
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
309
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
189
310
  }, wrap(async (a) => {
190
311
  const { input, jobType, notes } = await apiPost('/api/render/assemble', a); // a passes wholesale — resolution/captions/endCard/music/lockup/ttsVoice ride the body
191
312
  // LAW 8: render_ad honors render_plan.structure/duration — a >single-clip creative assembles as stitched ACTS
@@ -198,11 +319,12 @@ export function registerTools(server) {
198
319
 
199
320
 
200
321
  server.registerTool('make_template_ad', {
322
+ title: 'Make template ad',
201
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.",
202
324
  inputSchema: {
203
325
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
204
326
  },
205
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
327
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
206
328
  }, wrap(async (a) => {
207
329
  const r = await renderJob('templatead', { config: a.config }, 'MCP template ad');
208
330
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
@@ -215,6 +337,7 @@ export function registerTools(server) {
215
337
  }));
216
338
 
217
339
  server.registerTool('finish_video', {
340
+ title: 'Finish video',
218
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.",
219
342
  inputSchema: {
220
343
  videoUrl: z.string().describe('the served URL of the video to finish (from a previous render/job)'),
@@ -225,13 +348,14 @@ export function registerTools(server) {
225
348
  pills: z.boolean().optional().describe('default true — set false for a grain-only pass'),
226
349
  grain: z.boolean().optional().describe('default false — anti-AI film-grain finish'),
227
350
  },
228
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
351
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
229
352
  }, wrap(async (a) => {
230
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');
231
354
  return okVideo(`Finished video ready: ${r.url} [job ${r.jobId}]`, r);
232
355
  }));
233
356
 
234
357
  server.registerTool('fix_beat', {
358
+ title: 'Fix a video beat',
235
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.",
236
360
  inputSchema: {
237
361
  videoUrl: z.string().describe('the served URL of the master video to fix'),
@@ -241,17 +365,19 @@ export function registerTools(server) {
241
365
  refImage: z.string().optional().describe('optional product/style anchor image URL'),
242
366
  speechWindows: z.array(z.array(z.number())).optional().describe('[[start,end],...] windows with spoken lines — the fix window must not overlap these'),
243
367
  },
244
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
368
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
245
369
  }, wrap(async (a) => {
246
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');
247
371
  return okVideo(`Fixed beat spliced in: ${r.url} [job ${r.jobId}]`, r);
248
372
  }));
249
373
 
250
374
  server.registerTool('generate_video', {
251
- 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).',
252
377
  inputSchema: {
253
- 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)'),
254
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."),
255
381
  durationSeconds: z.number().optional().describe('clip length in seconds'),
256
382
  aspectRatio: z.string().optional().describe("default '9:16'"),
257
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'),
@@ -260,7 +386,7 @@ export function registerTools(server) {
260
386
  ttsVoice: z.string().optional().describe('voice name, e.g. Rachel / George'),
261
387
  musicMood: z.string().optional(),
262
388
  },
263
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
389
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
264
390
  }, wrap(async (a) => {
265
391
  const refImage = a.refImage ? await toRef(a.refImage) : undefined;
266
392
  // an agent that NAMES a model made a deliberate pick — modelExplicit gives it the server-side ask-don't-swap
@@ -270,6 +396,7 @@ export function registerTools(server) {
270
396
  }));
271
397
 
272
398
  server.registerTool('generate_avatar', {
399
+ title: 'Generate talking avatar',
273
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.',
274
401
  inputSchema: {
275
402
  image: z.string().describe('local path or URL of the presenter portrait'),
@@ -277,7 +404,7 @@ export function registerTools(server) {
277
404
  voice: z.string().optional().describe('voice name (Rachel/Sarah/George/Adam)'),
278
405
  resolution: z.string().optional().describe("'720p' (default) or '480p' draft"),
279
406
  },
280
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
407
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
281
408
  }, wrap(async (a) => {
282
409
  const image = await toRef(a.image);
283
410
  const r = await renderJob('avatar', { ...a, image }, 'MCP avatar');
@@ -285,6 +412,7 @@ export function registerTools(server) {
285
412
  }));
286
413
 
287
414
  server.registerTool('stitch_video', {
415
+ title: 'Stitch multi-scene video',
288
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.',
289
417
  inputSchema: {
290
418
  scenes: z.array(z.object({}).passthrough()).min(2).describe('array of scene objects (visual + optional voiceover/seconds)'),
@@ -295,7 +423,7 @@ export function registerTools(server) {
295
423
  model: z.string().optional(),
296
424
  durationSeconds: z.number().optional(),
297
425
  },
298
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
426
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
299
427
  }, wrap(async (a) => {
300
428
  // HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
301
429
  // clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
@@ -317,6 +445,7 @@ export function registerTools(server) {
317
445
  }));
318
446
 
319
447
  server.registerTool('get_job', {
448
+ title: 'Get render job',
320
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.',
321
450
  inputSchema: { id: z.string().describe('the job id, e.g. job_xxx') },
322
451
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -332,6 +461,7 @@ export function registerTools(server) {
332
461
 
333
462
  // ---------- skills (Higgsfield get_workflow_instructions parity: workflows ship as SKILL.md bundles) ----------
334
463
  server.registerTool('list_skills', {
464
+ title: 'List skills',
335
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.',
336
466
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
337
467
  }, wrap(async () => {
@@ -355,6 +485,7 @@ export function registerTools(server) {
355
485
  }));
356
486
 
357
487
  server.registerTool('get_skill', {
488
+ title: 'Get skill',
358
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.',
359
490
  inputSchema: { name: z.string().describe('bundle name from list_skills, e.g. hermoso-generate') },
360
491
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -367,6 +498,7 @@ export function registerTools(server) {
367
498
  }));
368
499
 
369
500
  server.registerTool('list_jobs', {
501
+ title: 'List render jobs',
370
502
  description: 'List the most recent render jobs + how many are currently running, so you can report on or resume in-flight work.',
371
503
  inputSchema: {}, annotations: { readOnlyHint: true, openWorldHint: false },
372
504
  }, wrap(async () => {
@@ -377,6 +509,7 @@ export function registerTools(server) {
377
509
 
378
510
  // ---------- research / discovery ----------
379
511
  server.registerTool('find_competitors', {
512
+ title: 'Find competitors',
380
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.",
381
514
  inputSchema: {
382
515
  domain: z.string().describe('the brand domain, e.g. yourbrand.com'),
@@ -390,6 +523,7 @@ export function registerTools(server) {
390
523
  }));
391
524
 
392
525
  server.registerTool('pull_competitor_ads', {
526
+ title: 'Pull competitor ads',
393
527
  description: 'Pull a brand\'s real running ads across Meta / Google / LinkedIn ad libraries (deduped, sorted, right page resolved). Spends ScrapeCreators credits.',
394
528
  inputSchema: {
395
529
  companyName: z.string().optional().describe('the advertiser name'),
@@ -406,6 +540,7 @@ export function registerTools(server) {
406
540
  }));
407
541
 
408
542
  server.registerTool('research_ads', {
543
+ title: 'Research ads',
409
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.',
410
545
  inputSchema: {
411
546
  query: z.string().describe('what to research, e.g. "the longest-running protein-pancake ads on Meta"'),
@@ -427,6 +562,7 @@ export function registerTools(server) {
427
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
428
563
 
429
564
  server.registerTool('search_meta_ads', {
565
+ title: 'Search Meta ads',
430
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).",
431
567
  inputSchema: {
432
568
  query: z.string().optional().describe('keyword search across ALL advertisers (use INSTEAD of companyName/pageId)'),
@@ -457,6 +593,7 @@ export function registerTools(server) {
457
593
  }));
458
594
 
459
595
  server.registerTool('search_google_ads', {
596
+ title: 'Search Google ads',
460
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.",
461
598
  inputSchema: {
462
599
  domain: z.string().optional().describe("the advertiser's domain, e.g. nike.com"),
@@ -474,6 +611,7 @@ export function registerTools(server) {
474
611
  }));
475
612
 
476
613
  server.registerTool('search_linkedin_ads', {
614
+ title: 'Search LinkedIn ads',
477
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).",
478
616
  inputSchema: {
479
617
  company: z.string().optional().describe('advertiser company name'),
@@ -495,6 +633,7 @@ export function registerTools(server) {
495
633
  }));
496
634
 
497
635
  server.registerTool('search_tiktok', {
636
+ title: 'Search TikTok',
498
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).",
499
638
  inputSchema: {
500
639
  query: z.string().describe('keyword or hashtag (no # needed)'),
@@ -515,6 +654,7 @@ export function registerTools(server) {
515
654
  }));
516
655
 
517
656
  server.registerTool('search_instagram', {
657
+ title: 'Search Instagram',
518
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).",
519
659
  inputSchema: {
520
660
  query: z.string().describe('keyword to search reels for'),
@@ -537,6 +677,7 @@ export function registerTools(server) {
537
677
  }));
538
678
 
539
679
  server.registerTool('search_youtube', {
680
+ title: 'Search YouTube',
540
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).",
541
682
  inputSchema: {
542
683
  query: z.string().describe('keyword to search videos for'),
@@ -553,6 +694,7 @@ export function registerTools(server) {
553
694
  }));
554
695
 
555
696
  server.registerTool('search_reddit', {
697
+ title: 'Search Reddit',
556
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).",
557
699
  inputSchema: {
558
700
  query: z.string().describe('what to search Reddit for'),
@@ -570,6 +712,7 @@ export function registerTools(server) {
570
712
  }));
571
713
 
572
714
  server.registerTool('search_threads', {
715
+ title: 'Search Threads',
573
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).",
574
717
  inputSchema: {
575
718
  query: z.string().describe('keyword to search Threads for'),
@@ -591,6 +734,7 @@ export function registerTools(server) {
591
734
  }));
592
735
 
593
736
  server.registerTool('scrapecreators_fetch', {
737
+ title: 'Fetch ScrapeCreators endpoint',
594
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.",
595
739
  inputSchema: {
596
740
  path: z.string().describe("exact SC endpoint path, e.g. '/v1/tiktok/profile' — non-allowlisted paths are rejected"),
@@ -605,6 +749,7 @@ export function registerTools(server) {
605
749
 
606
750
  // ---------- brand onboarding ----------
607
751
  server.registerTool('get_brand', {
752
+ title: 'Get saved brand',
608
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.',
609
754
  inputSchema: {},
610
755
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
@@ -617,6 +762,7 @@ export function registerTools(server) {
617
762
  }));
618
763
 
619
764
  server.registerTool('draft_brand', {
765
+ title: 'Draft brand profile',
620
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.',
621
767
  inputSchema: {
622
768
  domain: z.string().optional().describe('a website to scrape'),
@@ -625,7 +771,7 @@ export function registerTools(server) {
625
771
  platform: z.string().optional().describe('platform for socialHandle (instagram/tiktok/…)'),
626
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'),
627
773
  },
628
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
774
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
629
775
  }, wrap(async ({ save, ...a }) => {
630
776
  const d = await apiPost('/api/brand/draft', a);
631
777
  const p = d.profile || d;
@@ -645,6 +791,7 @@ export function registerTools(server) {
645
791
 
646
792
  // ---------- assets ----------
647
793
  server.registerTool('fetch_asset', {
794
+ title: 'Fetch asset',
648
795
  description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
649
796
  inputSchema: { url: z.string().describe('the asset url or /generated/ path'), name: z.string().optional() },
650
797
  annotations: { readOnlyHint: true, openWorldHint: false },
@@ -656,6 +803,7 @@ export function registerTools(server) {
656
803
 
657
804
  // ---------- post-production & analysis (Higgsfield-parity wave: each wraps an EXISTING worker/route) ----------
658
805
  server.registerTool('analyze_video', {
806
+ title: 'Analyze video',
659
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.",
660
808
  inputSchema: { url: z.string().describe('the video URL (a served /generated/ path or a public http(s) video)') },
661
809
  annotations: { readOnlyHint: true, openWorldHint: true },
@@ -670,6 +818,7 @@ export function registerTools(server) {
670
818
  }));
671
819
 
672
820
  server.registerTool('score_ad', {
821
+ title: 'Score ad',
673
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.",
674
823
  inputSchema: {
675
824
  url: z.string().describe('the ad asset URL (a /generated/ path or public URL)'),
@@ -685,49 +834,54 @@ export function registerTools(server) {
685
834
  }));
686
835
 
687
836
  server.registerTool('reframe_video', {
837
+ title: 'Reframe video',
688
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.",
689
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') },
690
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
840
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
691
841
  }, wrap(async ({ video, aspectRatio }) => {
692
842
  const r = await renderJob('reframe', { video, aspectRatio }, `Reframe → ${aspectRatio}`);
693
843
  return okVideo(`Reframed video (${aspectRatio}): ${r.url}`, r);
694
844
  }));
695
845
 
696
846
  server.registerTool('upscale_video', {
847
+ title: 'Upscale video',
697
848
  description: "Upscale a video to higher resolution (2x) for final delivery. Paid render; returns the served URL.",
698
849
  inputSchema: { video: z.string().describe('the source video URL') },
699
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
850
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
700
851
  }, wrap(async ({ video }) => {
701
852
  const r = await renderJob('upscale', { video, factor: 2 }, 'Upscale 2x');
702
853
  return okVideo(`Upscaled video: ${r.url}`, r);
703
854
  }));
704
855
 
705
856
  server.registerTool('dub_video', {
857
+ title: 'Dub video',
706
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.",
707
859
  inputSchema: {
708
860
  video: z.string().describe('the source video URL'),
709
861
  language: z.string().describe("target language, e.g. 'Spanish', 'de', 'French (Canada)'"),
710
862
  script: z.string().optional().describe('the original spoken script if known — improves translation fidelity'),
711
863
  },
712
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
864
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
713
865
  }, wrap(async ({ video, language, script }) => {
714
866
  const r = await renderJob('dub', { video, language, script: script || '' }, `Dub → ${language}`);
715
867
  return okVideo(`Localized video (${language}): ${r.url}`, r);
716
868
  }));
717
869
 
718
870
  server.registerTool('change_voice', {
871
+ title: 'Change narrator voice',
719
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.",
720
873
  inputSchema: {
721
874
  video: z.string().describe('the source video URL'),
722
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)"),
723
876
  },
724
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
877
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
725
878
  }, wrap(async ({ video, voice }) => {
726
879
  const r = await renderJob('voiceswap', { video, ...(voice ? { voice } : {}) }, 'Voice swap');
727
880
  return okVideo(`Voice-swapped video: ${r.url}`, r);
728
881
  }));
729
882
 
730
883
  server.registerTool('recast_motion', {
884
+ title: 'Recast motion',
731
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.",
732
886
  inputSchema: {
733
887
  image: z.string().describe("the actor/character image URL (who should appear)"),
@@ -735,13 +889,14 @@ export function registerTools(server) {
735
889
  prompt: z.string().optional().describe('optional scene/style guidance'),
736
890
  orientation: z.enum(['video', 'image']).optional().describe("which aspect to keep: the video's (default) or the image's"),
737
891
  },
738
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
892
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
739
893
  }, wrap(async ({ image, video, prompt = '', orientation = 'video' }) => {
740
894
  const r = await renderJob('motion', { image, video, prompt, orientation }, 'Motion recast');
741
895
  return okVideo(`Recast video: ${r.url}`, r);
742
896
  }));
743
897
 
744
898
  server.registerTool('plan_variations', {
899
+ title: 'Plan ad variations',
745
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.",
746
901
  inputSchema: {
747
902
  brand: z.union([z.string(), z.object({}).passthrough()]).optional().describe('brand name or profile object; OMIT to use the workspace’s saved brand'),
@@ -749,7 +904,7 @@ export function registerTools(server) {
749
904
  count: z.number().int().min(2).max(8).optional().describe('how many distinct variants (default 6)'),
750
905
  language: z.string().optional(),
751
906
  },
752
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
907
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
753
908
  }, wrap(async ({ brand, product, count = 6, language }) => {
754
909
  const brandObj = brand ? (typeof brand === 'string' ? { name: brand } : brand) : null;
755
910
  const d = await apiPost('/api/batch/plan', { brand: brandObj, product, count, language: language || '' });
@@ -775,13 +930,14 @@ export function registerTools(server) {
775
930
  };
776
931
 
777
932
  server.registerTool('competitor_teardown', {
933
+ title: 'Competitor teardown',
778
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).",
779
935
  inputSchema: {
780
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'),
781
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.'),
782
938
  language: z.string().optional().describe('output language (default English)'),
783
939
  },
784
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
940
+ annotations: { readOnlyHint: true, openWorldHint: true },
785
941
  }, wrap(async ({ competitor, ads, language }) => {
786
942
  const name = String(competitor?.name || '').trim();
787
943
  if (!name) throw new Error('competitor.name is required.');
@@ -803,6 +959,7 @@ export function registerTools(server) {
803
959
  }));
804
960
 
805
961
  server.registerTool('check_ad_policy', {
962
+ title: 'Check ad policy',
806
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.",
807
964
  inputSchema: {
808
965
  copy: z.string().describe('the ad copy / script / on-screen text to check'),
@@ -820,12 +977,13 @@ export function registerTools(server) {
820
977
  }));
821
978
 
822
979
  server.registerTool('remix_static', {
980
+ title: 'Remix a static ad',
823
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.",
824
982
  inputSchema: {
825
983
  imageUrl: z.string().describe('the URL of the static ad image to remix'),
826
984
  brandId: z.string().optional().describe('a brand id/name from list_brands to remix for; omit to use the active brand'),
827
985
  },
828
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
986
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
829
987
  }, wrap(async ({ imageUrl, brandId }) => {
830
988
  const brand = await activeBrand(brandId);
831
989
  if (!brand) throw new Error('No saved brand to remix for — onboard one with draft_brand, or pass a brandId from list_brands.');
@@ -838,6 +996,7 @@ export function registerTools(server) {
838
996
  }));
839
997
 
840
998
  server.registerTool('mine_angles', {
999
+ title: 'Mine customer angles',
841
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.",
842
1001
  inputSchema: {
843
1002
  brandId: z.string().optional().describe('a brand id/name from list_brands to mine for; omit to use the active brand'),
@@ -855,6 +1014,7 @@ export function registerTools(server) {
855
1014
 
856
1015
  // ---------- product-photo tools (Studio-chat parity) ----------
857
1016
  server.registerTool('list_product_photos', {
1017
+ title: 'List product photos',
858
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).",
859
1019
  inputSchema: {
860
1020
  brandId: z.string().optional().describe('a brand id/name from list_brands whose product library to list; omit to use the active brand'),
@@ -867,13 +1027,14 @@ export function registerTools(server) {
867
1027
  }));
868
1028
 
869
1029
  server.registerTool('set_product_image', {
1030
+ title: 'Set product photo',
870
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).",
871
1032
  inputSchema: {
872
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)'),
873
1034
  source_note: z.string().optional().describe('a short note on where it came from, e.g. "from their IG post"'),
874
1035
  brandId: z.string().optional().describe('a brand id/name from list_brands to lock the product for; omit to use the active brand'),
875
1036
  },
876
- annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1037
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
877
1038
  }, wrap(async ({ imageUrl, source_note, brandId }) => {
878
1039
  const brand = await activeBrand(brandId);
879
1040
  const d = await apiPost('/api/product/set-image', { imageUrl, source_note: source_note || '', brand: brand || {} });
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "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.",
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.",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"
@@ -14,14 +14,26 @@
14
14
  "@modelcontextprotocol/sdk": "^1.12.0"
15
15
  },
16
16
  "keywords": [
17
- "mcp",
18
- "model-context-protocol",
19
- "ai-ads",
20
17
  "ad-generator",
21
- "ugc",
18
+ "ad-library",
19
+ "ads",
20
+ "advertising",
21
+ "ai-ads",
22
+ "ai-agents",
23
+ "ai-video",
22
24
  "claude",
23
25
  "cli",
24
- "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"
25
37
  ],
26
38
  "homepage": "https://hermoso.ai",
27
39
  "repository": {