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.
- package/mcp/hermoso-mcp.mjs +4 -2
- package/mcp/http.mjs +2 -2
- package/mcp/tools.mjs +186 -25
- package/package.json +19 -7
package/mcp/hermoso-mcp.mjs
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "
|
|
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
|
-
"
|
|
18
|
+
"ad-library",
|
|
19
|
+
"ads",
|
|
20
|
+
"advertising",
|
|
21
|
+
"ai-ads",
|
|
22
|
+
"ai-agents",
|
|
23
|
+
"ai-video",
|
|
22
24
|
"claude",
|
|
23
25
|
"cli",
|
|
24
|
-
"
|
|
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": {
|